Skip to main content

sql_composer/parser/
compose.rs

1//! Parser for `:compose(target, @slot = path, ...)` macros.
2
3use std::path::PathBuf;
4
5use winnow::combinator::trace;
6use winnow::error::ParserError;
7use winnow::stream::{AsBStr, AsChar, Compare, Stream, StreamIsPartial};
8use winnow::token::{literal, take_while};
9use winnow::Parser;
10
11use crate::types::{ComposeRef, ComposeTarget, SlotAssignment};
12
13/// Parse a file path inside a compose macro: one or more characters that are
14/// not `)`, `,`, or whitespace.
15pub fn compose_path<'i, Input, Error>(input: &mut Input) -> Result<PathBuf, Error>
16where
17    Input: StreamIsPartial + Stream + Compare<&'i str>,
18    <Input as Stream>::Slice: AsBStr,
19    <Input as Stream>::Token: AsChar + Clone,
20    Error: ParserError<Input>,
21{
22    trace("compose_path", move |input: &mut Input| {
23        let path_str = take_while(1.., |c: <Input as Stream>::Token| {
24            let ch = c.as_char();
25            ch != ')' && ch != ',' && ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r'
26        })
27        .parse_next(input)?;
28        let path_str = String::from_utf8_lossy(path_str.as_bstr()).to_string();
29        Ok(PathBuf::from(path_str))
30    })
31    .parse_next(input)
32}
33
34/// Parse a slot name after `@`: one or more alphanumeric, hyphen, or underscore chars.
35/// Returns the name without the `@` prefix.
36fn slot_name<'i, Input, Error>(input: &mut Input) -> Result<String, Error>
37where
38    Input: StreamIsPartial + Stream + Compare<&'i str>,
39    <Input as Stream>::Slice: AsBStr,
40    <Input as Stream>::Token: AsChar + Clone,
41    Error: ParserError<Input>,
42{
43    trace("slot_name", move |input: &mut Input| {
44        literal("@").parse_next(input)?;
45        let name = take_while(1.., |c: <Input as Stream>::Token| {
46            let ch = c.as_char();
47            ch.is_alphanumeric() || ch == '-' || ch == '_'
48        })
49        .parse_next(input)?;
50        Ok(String::from_utf8_lossy(name.as_bstr()).to_string())
51    })
52    .parse_next(input)
53}
54
55/// Parse optional whitespace (spaces and tabs).
56fn opt_ws<'i, Input, Error>(input: &mut Input) -> Result<(), Error>
57where
58    Input: StreamIsPartial + Stream + Compare<&'i str>,
59    <Input as Stream>::Slice: AsBStr,
60    <Input as Stream>::Token: AsChar + Clone,
61    Error: ParserError<Input>,
62{
63    let _ = take_while(0.., |c: <Input as Stream>::Token| {
64        let ch = c.as_char();
65        ch == ' ' || ch == '\t'
66    })
67    .parse_next(input)?;
68    Ok(())
69}
70
71/// Parse a slot assignment: `@name = path`.
72fn slot_assignment<'i, Input, Error>(input: &mut Input) -> Result<SlotAssignment, Error>
73where
74    Input: StreamIsPartial + Stream + Compare<&'i str>,
75    <Input as Stream>::Slice: AsBStr,
76    <Input as Stream>::Token: AsChar + Clone,
77    Error: ParserError<Input>,
78{
79    trace("slot_assignment", move |input: &mut Input| {
80        let name = slot_name(input)?;
81        opt_ws(input)?;
82        literal("=").parse_next(input)?;
83        opt_ws(input)?;
84        let path = compose_path(input)?;
85        Ok(SlotAssignment { name, path })
86    })
87    .parse_next(input)
88}
89
90/// Parse a complete `:compose(target, @slot = path, ...)` macro.
91///
92/// Assumes the `:compose(` prefix has already been consumed. Parses the target
93/// (path or slot reference), optional slot assignments, and the closing `)`.
94pub fn compose<'i, Input, Error>(input: &mut Input) -> Result<ComposeRef, Error>
95where
96    Input: StreamIsPartial + Stream + Compare<&'i str>,
97    <Input as Stream>::Slice: AsBStr,
98    <Input as Stream>::Token: AsChar + Clone,
99    Error: ParserError<Input>,
100{
101    trace("compose", move |input: &mut Input| {
102        // Try to parse target as a slot reference (@name) or a file path.
103        let checkpoint = input.checkpoint();
104        let target = if let Ok(name) = slot_name::<_, Error>(input) {
105            // Check what follows — if `=` follows, this was actually a slot assignment
106            // as the first arg, which is invalid (target must come first). But actually,
107            // the target IS a slot reference like @filter, and `=` would only appear
108            // in a slot assignment. After a slot target, we expect `)` or `,`.
109            let ws_check = input.checkpoint();
110            opt_ws::<_, Error>(input).ok();
111            if literal::<_, _, Error>("=").parse_next(input).is_ok() {
112                // This looks like `@name = ...` which is not a valid target.
113                // Reset and try as a path (which will fail on `@`).
114                input.reset(&checkpoint);
115                let path = compose_path(input)?;
116                ComposeTarget::Path(path)
117            } else {
118                input.reset(&ws_check);
119                ComposeTarget::Slot(name)
120            }
121        } else {
122            input.reset(&checkpoint);
123            let path = compose_path(input)?;
124            ComposeTarget::Path(path)
125        };
126
127        // Parse optional slot assignments: `, @name = path` repeated
128        let mut slots = Vec::new();
129        loop {
130            opt_ws::<_, Error>(input).ok();
131            let comma_check = input.checkpoint();
132            if literal::<_, _, Error>(",").parse_next(input).is_ok() {
133                opt_ws::<_, Error>(input).ok();
134                let assignment = slot_assignment(input)?;
135                slots.push(assignment);
136            } else {
137                input.reset(&comma_check);
138                break;
139            }
140        }
141
142        literal(")").parse_next(input)?;
143        Ok(ComposeRef { target, slots })
144    })
145    .parse_next(input)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use winnow::error::ContextError;
152
153    type TestInput<'a> = &'a str;
154
155    #[test]
156    fn test_compose_simple() {
157        let mut input: TestInput = "templates/get_user.tql)";
158        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
159        assert_eq!(
160            result.target,
161            ComposeTarget::Path(PathBuf::from("templates/get_user.tql"))
162        );
163        assert!(result.slots.is_empty());
164        assert_eq!(input, "");
165    }
166
167    #[test]
168    fn test_compose_relative_path() {
169        let mut input: TestInput = "src/tests/simple-template.tql)";
170        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
171        assert_eq!(
172            result.target,
173            ComposeTarget::Path(PathBuf::from("src/tests/simple-template.tql"))
174        );
175        assert!(result.slots.is_empty());
176    }
177
178    #[test]
179    fn test_compose_with_trailing() {
180        let mut input: TestInput = "get_user.tql) AND active";
181        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
182        assert_eq!(
183            result.target,
184            ComposeTarget::Path(PathBuf::from("get_user.tql"))
185        );
186        assert!(result.slots.is_empty());
187        assert_eq!(input, " AND active");
188    }
189
190    #[test]
191    fn test_compose_single_slot() {
192        let mut input: TestInput = "shared/base.sqlc, @filter = filters/by_color.sqlc)";
193        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
194        assert_eq!(
195            result.target,
196            ComposeTarget::Path(PathBuf::from("shared/base.sqlc"))
197        );
198        assert_eq!(result.slots.len(), 1);
199        assert_eq!(result.slots[0].name, "filter");
200        assert_eq!(
201            result.slots[0].path,
202            PathBuf::from("filters/by_color.sqlc")
203        );
204    }
205
206    #[test]
207    fn test_compose_multiple_slots() {
208        let mut input: TestInput =
209            "shared/report.sqlc, @source = shared/details.sqlc, @filter = filters/color.sqlc)";
210        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
211        assert_eq!(
212            result.target,
213            ComposeTarget::Path(PathBuf::from("shared/report.sqlc"))
214        );
215        assert_eq!(result.slots.len(), 2);
216        assert_eq!(result.slots[0].name, "source");
217        assert_eq!(
218            result.slots[0].path,
219            PathBuf::from("shared/details.sqlc")
220        );
221        assert_eq!(result.slots[1].name, "filter");
222        assert_eq!(
223            result.slots[1].path,
224            PathBuf::from("filters/color.sqlc")
225        );
226    }
227
228    #[test]
229    fn test_compose_slot_reference() {
230        let mut input: TestInput = "@filter)";
231        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
232        assert_eq!(
233            result.target,
234            ComposeTarget::Slot("filter".into())
235        );
236        assert!(result.slots.is_empty());
237    }
238
239    #[test]
240    fn test_compose_slot_reference_with_assignments() {
241        let mut input: TestInput = "@slot, @inner = some_file.sqlc)";
242        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
243        assert_eq!(
244            result.target,
245            ComposeTarget::Slot("slot".into())
246        );
247        assert_eq!(result.slots.len(), 1);
248        assert_eq!(result.slots[0].name, "inner");
249        assert_eq!(result.slots[0].path, PathBuf::from("some_file.sqlc"));
250    }
251
252    #[test]
253    fn test_slot_names_with_hyphens_underscores() {
254        let mut input: TestInput = "base.sqlc, @my-filter = f.sqlc, @other_slot = g.sqlc)";
255        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
256        assert_eq!(result.slots.len(), 2);
257        assert_eq!(result.slots[0].name, "my-filter");
258        assert_eq!(result.slots[1].name, "other_slot");
259    }
260
261    #[test]
262    fn test_whitespace_around_equals() {
263        let mut input: TestInput = "base.sqlc, @filter  =  filters/x.sqlc)";
264        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
265        assert_eq!(result.slots.len(), 1);
266        assert_eq!(result.slots[0].name, "filter");
267        assert_eq!(result.slots[0].path, PathBuf::from("filters/x.sqlc"));
268    }
269
270    #[test]
271    fn test_path_stops_at_comma() {
272        let mut input: TestInput = "shared/base.sqlc, @s = f.sqlc)";
273        let result = compose::<_, ContextError>.parse_next(&mut input).unwrap();
274        assert_eq!(
275            result.target,
276            ComposeTarget::Path(PathBuf::from("shared/base.sqlc"))
277        );
278    }
279}