1use miette::SourceSpan;
2use winnow::{
3 Parser, Stateful,
4 combinator::eof,
5 error::{ContextError, ParseError},
6};
7
8use crate::arena::Arena;
9
10type Input<'a> = Stateful<&'a str, &'a Arena>;
12
13pub fn parse<'a>(arena: &'a Arena, input: &'a str) -> Result<Vec<PathSegment<'a>>, BadPath> {
20 let stateful = Input {
21 input,
22 state: arena,
23 };
24 (self::parser::template, eof)
25 .map(|(segments, _)| segments)
26 .parse(stateful)
27 .map_err(BadPath::from_parse_error)
28}
29
30#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
33pub struct PathSegment<'input>(&'input [PathFragment<'input>]);
34
35impl<'input> PathSegment<'input> {
36 pub fn fragments(&self) -> &'input [PathFragment<'input>] {
38 self.0
39 }
40}
41
42#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
44pub enum PathFragment<'input> {
45 Literal(&'input str),
47 Param(&'input str),
49}
50
51mod parser {
52 use super::*;
53
54 use std::borrow::Cow;
55
56 use winnow::{
57 Parser,
58 combinator::{alt, delimited, repeat},
59 token::take_while,
60 };
61
62 pub fn template<'a>(input: &mut Input<'a>) -> winnow::Result<Vec<PathSegment<'a>>> {
63 alt((
64 ('/', segment, template)
65 .map(|(_, head, tail)| std::iter::once(head).chain(tail).collect()),
66 ('/', segment).map(|(_, segment)| vec![segment]),
67 '/'.map(|_| vec![PathSegment::default()]),
68 ))
69 .parse_next(input)
70 }
71
72 fn segment<'a>(input: &mut Input<'a>) -> winnow::Result<PathSegment<'a>> {
73 repeat(1.., fragment)
74 .map(|fragments: Vec<_>| PathSegment(input.state.alloc_slice_copy(&fragments)))
75 .parse_next(input)
76 }
77
78 fn fragment<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
79 alt((param, literal)).parse_next(input)
80 }
81
82 pub fn param<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
83 delimited('{', take_while(1.., |c| c != '{' && c != '}'), '}')
84 .map(PathFragment::Param)
85 .parse_next(input)
86 }
87
88 pub fn literal<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
89 take_while(1.., |c| {
90 matches!(c,
91 'A'..='Z' | 'a'..='z' | '0'..='9' |
92 '-' | '.' | '_' | '~' | ':' | '@' |
93 '!' | '$' | '&' | '\'' | '(' | ')' |
94 '*' | '+' | ',' | ';' | '=' | '%'
95 )
96 })
97 .verify_map(|text: &str| {
98 let decoded = percent_encoding::percent_decode_str(text)
99 .decode_utf8()
100 .ok()?;
101 Some(PathFragment::Literal(match decoded {
102 Cow::Borrowed(s) => s,
103 Cow::Owned(s) => input.state.alloc_str(&s),
104 }))
105 })
106 .parse_next(input)
107 }
108}
109
110#[derive(Debug, miette::Diagnostic, thiserror::Error)]
112#[error("invalid URL path template")]
113pub struct BadPath {
114 #[source_code]
115 code: String,
116 #[label]
117 span: SourceSpan,
118}
119
120impl BadPath {
121 fn from_parse_error(error: ParseError<Input<'_>, ContextError>) -> Self {
122 let stateful = error.input();
123 Self {
124 code: stateful.input.to_owned(),
125 span: error.char_span().into(),
126 }
127 }
128}
129
130#[cfg(test)]
131mod test {
132 use super::*;
133
134 use crate::tests::assert_matches;
135
136 #[test]
137 fn test_root_path() {
138 let arena = Arena::new();
139 let result = parse(&arena, "/").unwrap();
140
141 assert_matches!(&*result, [PathSegment([])]);
142 }
143
144 #[test]
145 fn test_simple_literal() {
146 let arena = Arena::new();
147 let result = parse(&arena, "/users").unwrap();
148
149 assert_matches!(&*result, [PathSegment([PathFragment::Literal("users")])]);
150 }
151
152 #[test]
153 fn test_trailing_slash() {
154 let arena = Arena::new();
155 let result = parse(&arena, "/users/").unwrap();
156
157 assert_matches!(
158 &*result,
159 [
160 PathSegment([PathFragment::Literal("users")]),
161 PathSegment([]),
162 ],
163 );
164 }
165
166 #[test]
167 fn test_simple_template() {
168 let arena = Arena::new();
169 let result = parse(&arena, "/users/{userId}").unwrap();
170
171 assert_matches!(
172 &*result,
173 [
174 PathSegment([PathFragment::Literal("users")]),
175 PathSegment([PathFragment::Param("userId")]),
176 ],
177 );
178 }
179
180 #[test]
181 fn test_nested_path() {
182 let arena = Arena::new();
183 let result = parse(&arena, "/api/v1/resources/{resourceId}").unwrap();
184
185 assert_matches!(
186 &*result,
187 [
188 PathSegment([PathFragment::Literal("api")]),
189 PathSegment([PathFragment::Literal("v1")]),
190 PathSegment([PathFragment::Literal("resources")]),
191 PathSegment([PathFragment::Param("resourceId")]),
192 ],
193 );
194 }
195
196 #[test]
197 fn test_multiple_templates() {
198 let arena = Arena::new();
199 let result = parse(&arena, "/users/{userId}/posts/{postId}").unwrap();
200
201 assert_matches!(
202 &*result,
203 [
204 PathSegment([PathFragment::Literal("users")]),
205 PathSegment([PathFragment::Param("userId")]),
206 PathSegment([PathFragment::Literal("posts")]),
207 PathSegment([PathFragment::Param("postId")]),
208 ],
209 );
210 }
211
212 #[test]
213 fn test_literal_with_extension() {
214 let arena = Arena::new();
215 let result = parse(
216 &arena,
217 "/v1/storage/workspace/{workspace}/documents/download/{documentId}.pdf",
218 )
219 .unwrap();
220
221 assert_matches!(
222 &*result,
223 [
224 PathSegment([PathFragment::Literal("v1")]),
225 PathSegment([PathFragment::Literal("storage")]),
226 PathSegment([PathFragment::Literal("workspace")]),
227 PathSegment([PathFragment::Param("workspace")]),
228 PathSegment([PathFragment::Literal("documents")]),
229 PathSegment([PathFragment::Literal("download")]),
230 PathSegment([
231 PathFragment::Param("documentId"),
232 PathFragment::Literal(".pdf"),
233 ]),
234 ],
235 );
236 }
237
238 #[test]
239 fn test_mixed_literal_and_param() {
240 let arena = Arena::new();
241 let result = parse(
242 &arena,
243 "/v1/storage/workspace/{workspace}/documents/download/report-{documentId}.pdf",
244 )
245 .unwrap();
246
247 assert_matches!(
248 &*result,
249 [
250 PathSegment([PathFragment::Literal("v1")]),
251 PathSegment([PathFragment::Literal("storage")]),
252 PathSegment([PathFragment::Literal("workspace")]),
253 PathSegment([PathFragment::Param("workspace")]),
254 PathSegment([PathFragment::Literal("documents")]),
255 PathSegment([PathFragment::Literal("download")]),
256 PathSegment([
257 PathFragment::Literal("report-"),
258 PathFragment::Param("documentId"),
259 PathFragment::Literal(".pdf"),
260 ]),
261 ],
262 );
263 }
264
265 #[test]
266 fn test_double_slash() {
267 let arena = Arena::new();
268 assert!(parse(&arena, "/users//a").is_err());
270 }
271
272 #[test]
273 fn test_invalid_chars_in_template() {
274 let arena = Arena::new();
275 assert!(parse(&arena, "/users/{user/{id}}").is_err());
278 }
279}