1use 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
13pub 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
34fn 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
55fn 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
71fn 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
90pub 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 let checkpoint = input.checkpoint();
104 let target = if let Ok(name) = slot_name::<_, Error>(input) {
105 let ws_check = input.checkpoint();
110 opt_ws::<_, Error>(input).ok();
111 if literal::<_, _, Error>("=").parse_next(input).is_ok() {
112 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 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}