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