ferrovue_router/lib.rs
1//! Vue Router's location matching, for `<RouterLink>` and `useRoute()` on the server: part of
2//! [ferrovue](https://docs.rs/ferrovue)'s runtime.
3//!
4//! # Example
5//!
6//! ```
7//! # use ferrovue_router as ferrovue;
8//! let router = ferrovue::Router::new(&["/", "/blog/:slug"]);
9//! let route = router.at("/blog/hello");
10//! let link = route.link("/blog/hello");
11//! assert_eq!((link.href.as_str(), link.active, link.exact), ("/blog/hello", true, true));
12//! assert!(!route.link("/").active);
13//! ```
14#![warn(rustdoc::missing_crate_level_docs)]
15
16/// The routes, in the order vue-router tries them.
17///
18/// # Example
19///
20/// ```
21/// # use ferrovue_router as ferrovue;
22/// use ferrovue::Router;
23///
24/// let router = Router::new(&["/", "/blog/:slug", "/docs/:path(.*)"]);
25/// let route = router.at("/docs/guide/routing");
26/// assert_eq!(route.param("path"), Some("guide/routing"));
27///
28/// // One router serves every request, from any thread.
29/// fn shared<T: Send + Sync>(_: &T) {}
30/// shared(&router);
31/// ```
32pub struct Router {
33 routes: Vec<Pattern>,
34 base: String,
35}
36
37/// The base, and each route's full path with its name, in the order vue-router tries them.
38///
39/// # Example
40///
41/// ```
42/// # use ferrovue_router as ferrovue;
43/// let router = ferrovue::Router::named(&[("/", Some("home")), ("/users/:id", None)]).with_base("/app/");
44/// assert_eq!(
45/// format!("{router:?}"),
46/// r#"Router { base: "/app", routes: {"/": Some("home"), "/users/:id": None} }"#
47/// );
48/// ```
49impl std::fmt::Debug for Router {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 let routes = self.routes.iter().map(|r| (&r.path, &r.name));
52 f.debug_struct("Router")
53 .field("base", &self.base)
54 .field("routes", &DebugMap(routes))
55 .finish()
56 }
57}
58
59struct DebugMap<I>(I);
60
61impl<K: std::fmt::Debug, V: std::fmt::Debug, I: Iterator<Item = (K, V)> + Clone> std::fmt::Debug
62 for DebugMap<I>
63{
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_map().entries(self.0.clone()).finish()
66 }
67}
68
69struct Pattern {
70 path: String,
71 parent: Option<usize>,
72 tokens: Vec<Token>,
73 score: Vec<i32>,
74 name: Option<String>,
75 matchable: bool,
76}
77
78enum Token {
79 Static(String),
80 Param {
81 name: String,
82 wildcard: bool,
83 optional: bool,
84 },
85}
86
87type Values = Vec<Option<String>>;
88
89const ROOT: i32 = 90;
90const STATIC: i32 = 80;
91const PARAM: i32 = 60;
92const OPTIONAL: i32 = 52;
93const WILDCARD: i32 = 20;
94
95impl Router {
96 /// A router over these paths, written as vue-router writes them: `/users/:id`, `/:lang?/about`,
97 /// `/docs/:section/:page(.*)`.
98 ///
99 /// # Panics
100 ///
101 /// On a path that does not start with `/`, a parameter that is not `:name`, `:name?` or
102 /// `:name(.*)`, a `(.*)` anywhere but the last segment, or a static segment that is not plain
103 /// ASCII text. The paths are part of the program, so this is a programming error, found the
104 /// first time the router is built.
105 ///
106 /// # Example
107 ///
108 /// ```
109 /// # use ferrovue_router as ferrovue;
110 /// let router = ferrovue::Router::new(&["/", "/users/:id"]);
111 /// assert_eq!(router.at("/users/7").param("id"), Some("7"));
112 /// assert_eq!(router.at("/nowhere").param("id"), None);
113 /// ```
114 #[must_use]
115 pub fn new(paths: &[&str]) -> Router {
116 Router::named(&paths.iter().map(|p| (*p, None)).collect::<Vec<_>>())
117 }
118
119 /// A router over these routes, each a path and, if it has one, its name.
120 ///
121 /// # Panics
122 ///
123 /// As [`Router::new`] does, and on two routes with the same name.
124 ///
125 /// # Example
126 ///
127 /// ```
128 /// # use ferrovue_router as ferrovue;
129 /// use ferrovue::Router;
130 ///
131 /// let router = Router::named(&[("/", Some("home")), ("/users/:id", Some("user"))]).with_base("/app/");
132 /// let route = router.at("/users/7?tab=posts#bio");
133 /// assert_eq!(route.param("id"), Some("7"));
134 /// assert_eq!(route.name(), Some("user"));
135 /// assert_eq!(route.hash(), "#bio");
136 ///
137 /// let link = route.link_named("user", &[("id", "7")], "", "");
138 /// assert_eq!(link.href, "/app/users/7");
139 /// assert!(link.active);
140 /// ```
141 #[must_use]
142 pub fn named(routes: &[(&str, Option<&str>)]) -> Router {
143 let defs: Vec<RouteDef<'_>> = routes
144 .iter()
145 .map(|(path, name)| RouteDef {
146 name: *name,
147 ..RouteDef::new(path)
148 })
149 .collect();
150 Router::tree(&defs)
151 }
152
153 /// A router over nested routes: each child's path is joined to its parent's, unless it starts
154 /// with `/`, and an empty one is its parent's own, its default child.
155 ///
156 /// # Panics
157 ///
158 /// As [`Router::named`] does.
159 ///
160 /// # Example
161 ///
162 /// ```
163 /// # use ferrovue_router as ferrovue;
164 /// use ferrovue::{RouteDef, Router};
165 ///
166 /// let router = Router::tree(&[RouteDef::new("/users/:id")
167 /// .with_name("user")
168 /// .with_children(&[RouteDef::new("posts").with_name("user-posts")])]);
169 /// let here = router.at("/users/7/posts");
170 /// let parent = here.link("/users/7");
171 /// // The parent is active on its child's page, but not exactly.
172 /// assert!(parent.active && !parent.exact);
173 /// ```
174 #[must_use]
175 pub fn tree(defs: &[RouteDef<'_>]) -> Router {
176 fn add(defs: &[RouteDef<'_>], parent: Option<usize>, records: &mut Vec<Pattern>) {
177 for d in defs {
178 let path = match parent {
179 Some(p) if !d.path.starts_with('/') => {
180 let parent_path = &records[p].path;
181 let slash = if parent_path.ends_with('/') || d.path.is_empty() {
182 ""
183 } else {
184 "/"
185 };
186 format!("{parent_path}{slash}{}", d.path)
187 }
188 _ => d.path.to_owned(),
189 };
190 let mut pattern = Pattern::new(&path, d.name.map(str::to_owned));
191 pattern.parent = parent;
192 pattern.matchable = d.view || d.name.is_some();
193 records.push(pattern);
194 let me = records.len() - 1;
195 add(d.children, Some(me), records);
196 }
197 }
198 let mut records: Vec<Pattern> = Vec::new();
199 add(defs, None, &mut records);
200 for (i, a) in records.iter().enumerate() {
201 if let Some(name) = &a.name {
202 let again = records[i + 1..]
203 .iter()
204 .any(|b| b.name.as_ref() == Some(name));
205 assert!(!again, "two routes are called {name:?}");
206 }
207 }
208 let mut order: Vec<usize> = Vec::new();
209 for i in (0..records.len()).filter(|&i| records[i].matchable) {
210 let (mut lower, mut upper) = (0, order.len());
211 while lower != upper {
212 let mid = (lower + upper) / 2;
213 if compare(&records[i].score, &records[order[mid]].score)
214 == std::cmp::Ordering::Less
215 {
216 upper = mid;
217 } else {
218 lower = mid + 1;
219 }
220 }
221 let mut ancestor = records[i].parent;
222 while let Some(a) = ancestor {
223 if records[a].matchable
224 && compare(&records[i].score, &records[a].score) == std::cmp::Ordering::Equal
225 {
226 if let Some(at) = order[..upper].iter().rposition(|&x| x == a) {
227 upper = at;
228 }
229 break;
230 }
231 ancestor = records[a].parent;
232 }
233 order.insert(upper, i);
234 }
235 order.extend((0..records.len()).filter(|&i| !records[i].matchable));
236 let mut position = vec![0; records.len()];
237 for (at, &i) in order.iter().enumerate() {
238 position[i] = at;
239 }
240 let mut slots: Vec<Option<Pattern>> = records.into_iter().map(Some).collect();
241 let routes = order
242 .iter()
243 .map(|&i| {
244 let mut p = slots[i].take().expect("each record once");
245 p.parent = p.parent.map(|parent| position[parent]);
246 p
247 })
248 .collect();
249 Router {
250 routes,
251 base: String::new(),
252 }
253 }
254
255 fn chain(&self, mut i: usize) -> Vec<usize> {
256 let mut chain = vec![i];
257 while let Some(p) = self.routes[i].parent {
258 chain.push(p);
259 i = p;
260 }
261 chain.reverse();
262 chain
263 }
264
265 fn params<'a>(&'a self, i: usize, values: &'a [Option<String>]) -> Vec<(&'a str, &'a str)> {
266 self.routes[i]
267 .tokens
268 .iter()
269 .filter_map(|t| match t {
270 Token::Param { name, .. } => Some(name.as_str()),
271 Token::Static(_) => None,
272 })
273 .zip(values)
274 .filter_map(|(name, value)| Some((name, value.as_deref()?)))
275 .collect()
276 }
277
278 /// The same routes under a base path, as `createWebHistory(base)` serves them: every `href`
279 /// starts with it. `/app/` and `app` both mean `/app`.
280 ///
281 /// # Example
282 ///
283 /// ```
284 /// # use ferrovue_router as ferrovue;
285 /// let router = ferrovue::Router::new(&["/", "/blog/:slug"]).with_base("/app/");
286 /// // A request for /app/blog/intro, with the base taken off.
287 /// let route = router.at("/blog/intro");
288 /// let link = route.link("/blog/intro");
289 /// assert_eq!(link.href, "/app/blog/intro");
290 /// assert!(link.exact);
291 /// ```
292 #[must_use]
293 pub fn with_base(mut self, base: &str) -> Router {
294 let mut b = if base.is_empty() || base.starts_with('/') || base.starts_with('#') {
295 base.to_owned()
296 } else {
297 format!("/{base}")
298 };
299 if b.ends_with('/') {
300 b.pop();
301 }
302 if let Some(i) = b.find('#')
303 && i > 0
304 {
305 b = b[i..].to_owned();
306 }
307 self.base = b;
308 self
309 }
310
311 /// The reader's location: a path, with any query and hash, and without the history's base.
312 ///
313 /// Pass the request's path and query as they arrived, percent-encoding included. A location
314 /// that matches no route still gives a [`Route`], whose [`Route::name`] and [`Route::param`]
315 /// are `None` and whose links are never active.
316 ///
317 /// # Example
318 ///
319 /// ```
320 /// # use ferrovue_router as ferrovue;
321 /// let router = ferrovue::Router::new(&["/", "/search"]);
322 /// let route = router.at("/search?q=caf%C3%A9&page=2#results");
323 /// assert_eq!(route.path(), "/search");
324 /// assert_eq!(route.query("q").attr_value(), Some("café"));
325 /// assert_eq!(route.hash(), "#results");
326 /// ```
327 #[must_use]
328 pub fn at(&self, location: &str) -> Route<'_> {
329 let (path, full_path) = parse_url(location, "/");
330 let hash_pos = location.find('#');
331 let hash = hash_pos.map_or_else(String::new, |h| decode(&location[h..]));
332 let search = location
333 .find('?')
334 .filter(|&s| hash_pos.is_none_or(|h| s < h))
335 .map_or("", |s| {
336 &location[s + 1..hash_pos.filter(|&h| h > 0).unwrap_or(location.len())]
337 });
338 Route {
339 router: self,
340 matched: self.matched(&path),
341 path,
342 hash,
343 query: parse_query(search),
344 full_path,
345 }
346 }
347
348 fn matched(&self, path: &str) -> Option<(usize, Values)> {
349 self.routes
350 .iter()
351 .enumerate()
352 .filter(|(_, r)| r.matchable)
353 .find_map(|(i, r)| r.parse(path).map(|params| (i, params)))
354 }
355}
356
357/// A route as the routes file lists it: a path, its name if it has one, whether it shows a
358/// component, and the routes nested in it, rendered by the `<RouterView>` in its component.
359///
360/// Build one with [`RouteDef::new`] and its `with_` methods, which are `const`, so a route table
361/// can be a `const` or a `static`.
362///
363/// # Example
364///
365/// ```
366/// # use ferrovue_router as ferrovue;
367/// use ferrovue::{RouteDef, Router};
368///
369/// const ROUTES: &[RouteDef<'static>] = &[
370/// RouteDef::new("/").with_name("home"),
371/// RouteDef::new("/books").with_view(false).with_children(&[
372/// RouteDef::new(":id").with_name("book"),
373/// ]),
374/// ];
375///
376/// let router = Router::tree(ROUTES);
377/// assert_eq!(router.at("/books/dune").name(), Some("book"));
378/// assert_eq!(router.at("/books").name(), None);
379/// ```
380#[derive(Clone, Copy, Debug)]
381#[non_exhaustive]
382pub struct RouteDef<'a> {
383 /// The path, relative to the parent's unless it starts with `/`; empty for a default child.
384 pub path: &'a str,
385 /// The route's name.
386 pub name: Option<&'a str>,
387 /// Whether it shows a component. A route with neither a view nor a name only groups the routes
388 /// nested in it, as a folder of file-based routes does, and vue-router never matches it.
389 pub view: bool,
390 /// The routes nested in it.
391 pub children: &'a [RouteDef<'a>],
392}
393
394impl<'a> RouteDef<'a> {
395 /// A route at `path` that shows a component, with no name and no nested routes.
396 ///
397 /// # Example
398 ///
399 /// ```
400 /// # use ferrovue_router as ferrovue;
401 /// let def = ferrovue::RouteDef::new("/about");
402 /// assert_eq!(def.path, "/about");
403 /// assert_eq!(def.name, None);
404 /// assert!(def.view && def.children.is_empty());
405 /// ```
406 #[must_use]
407 pub const fn new(path: &'a str) -> RouteDef<'a> {
408 RouteDef {
409 path,
410 name: None,
411 view: true,
412 children: &[],
413 }
414 }
415
416 /// The route, named `name`.
417 ///
418 /// # Example
419 ///
420 /// ```
421 /// # use ferrovue_router as ferrovue;
422 /// let def = ferrovue::RouteDef::new("/users/:id").with_name("user");
423 /// assert_eq!(def.name, Some("user"));
424 /// ```
425 #[must_use]
426 pub const fn with_name(self, name: &'a str) -> RouteDef<'a> {
427 RouteDef {
428 name: Some(name),
429 ..self
430 }
431 }
432
433 /// The route, showing a component when `view` is true. A route with neither a view nor a name
434 /// only groups the routes nested in it.
435 ///
436 /// # Example
437 ///
438 /// ```
439 /// # use ferrovue_router as ferrovue;
440 /// let def = ferrovue::RouteDef::new("/books").with_view(false);
441 /// assert!(!def.view);
442 /// ```
443 #[must_use]
444 pub const fn with_view(self, view: bool) -> RouteDef<'a> {
445 RouteDef { view, ..self }
446 }
447
448 /// The route, with `children` nested in it.
449 ///
450 /// # Example
451 ///
452 /// ```
453 /// # use ferrovue_router as ferrovue;
454 /// use ferrovue::RouteDef;
455 ///
456 /// const USER: RouteDef<'static> = RouteDef::new("/users/:id").with_children(&[
457 /// RouteDef::new(""),
458 /// RouteDef::new("posts"),
459 /// ]);
460 /// assert_eq!(USER.children[1].path, "posts");
461 /// ```
462 #[must_use]
463 pub const fn with_children(self, children: &'a [RouteDef<'a>]) -> RouteDef<'a> {
464 RouteDef { children, ..self }
465 }
466}
467
468/// Where the reader is, which is what every link on the page is compared with.
469///
470/// # Example
471///
472/// ```
473/// # use ferrovue_router as ferrovue;
474/// let router = ferrovue::Router::named(&[("/", Some("home")), ("/blog/:slug", Some("post"))]);
475/// let route = router.at("/blog/intro?sort=new#comments");
476///
477/// assert_eq!(route.name(), Some("post"));
478/// assert_eq!(route.param("slug"), Some("intro"));
479/// assert_eq!(route.path(), "/blog/intro");
480/// assert_eq!(route.full_path(), "/blog/intro?sort=new#comments");
481/// assert!(route.query("sort").is("new"));
482/// assert_eq!(route.hash(), "#comments");
483///
484/// // What `<RouterLink to="/">` writes from here.
485/// let home = route.link("/");
486/// assert_eq!(home.href, "/");
487/// assert!(!home.active);
488/// ```
489pub struct Route<'r> {
490 router: &'r Router,
491 path: String,
492 hash: String,
493 matched: Option<(usize, Values)>,
494 query: Vec<(String, Vec<Option<String>>)>,
495 full_path: String,
496}
497
498/// One value of `route.query`, as vue-router parses it: a key may be absent, given without a value
499/// (`?flag`, which is `null`), given once, or given more than once, which makes an array.
500///
501/// # Example
502///
503/// ```
504/// # use ferrovue_router as ferrovue;
505/// use ferrovue::{Query, Router};
506///
507/// let router = Router::new(&["/search"]);
508/// let route = router.at("/search?q=rust&tag=a&tag=b&flag");
509///
510/// assert_eq!(route.query("q"), Query::One("rust"));
511/// assert_eq!(route.query("flag"), Query::Null);
512/// assert_eq!(route.query("nothing"), Query::Absent);
513/// assert!(route.query("tag").is_array());
514/// ```
515#[derive(Clone, Copy, Debug, PartialEq)]
516#[non_exhaustive]
517pub enum Query<'r> {
518 /// No such key: `undefined`.
519 Absent,
520 /// `?flag`: `null`.
521 Null,
522 /// `?q=text`, decoded.
523 One(&'r str),
524 /// `?q=a&q=b`: an array, in which a value written without `=` is `null`.
525 Many(&'r [Option<String>]),
526}
527
528impl<'r> Query<'r> {
529 /// `{{ route.query.q }}`, escaped: nothing for `undefined` and `null`, the text for a string,
530 /// and an array as `toDisplayString` writes one: `JSON.stringify(value, null, 2)`.
531 ///
532 /// # Example
533 ///
534 /// ```
535 /// # use ferrovue_router as ferrovue;
536 /// let router = ferrovue::Router::new(&["/search"]);
537 /// let route = router.at("/search?q=a%3Cb&tag=x&tag=y");
538 ///
539 /// let mut out = String::new();
540 /// route.query("q").write_display(&mut out);
541 /// assert_eq!(out, "a<b");
542 ///
543 /// out.clear();
544 /// route.query("tag").write_display(&mut out);
545 /// assert_eq!(out, "[\n "x",\n "y"\n]");
546 /// ```
547 pub fn write_display(&self, out: &mut String) {
548 match self {
549 Query::Absent | Query::Null => {}
550 Query::One(s) => ferrovue_core::escape_into(out, s),
551 Query::Many(values) => {
552 let mut json = String::from("[\n");
553 for (i, v) in values.iter().enumerate() {
554 if i > 0 {
555 json.push_str(",\n");
556 }
557 json.push_str(" ");
558 match v {
559 Some(s) => json.push_str(&serde_json::to_string(s).unwrap_or_default()),
560 None => json.push_str("null"),
561 }
562 }
563 json.push_str("\n]");
564 ferrovue_core::escape_into(out, &json);
565 }
566 }
567 }
568
569 /// JavaScript's truthiness: a non-empty string or an array.
570 ///
571 /// # Example
572 ///
573 /// ```
574 /// # use ferrovue_router as ferrovue;
575 /// use ferrovue::Query;
576 ///
577 /// assert!(Query::One("x").truthy());
578 /// assert!(!Query::One("").truthy());
579 /// assert!(!Query::Null.truthy() && !Query::Absent.truthy());
580 /// ```
581 #[must_use]
582 pub fn truthy(&self) -> bool {
583 match self {
584 Query::One(s) => !s.is_empty(),
585 Query::Many(_) => true,
586 Query::Absent | Query::Null => false,
587 }
588 }
589
590 /// `route.query.q === text`: only a single value can equal a string.
591 #[must_use]
592 pub fn is(&self, text: &str) -> bool {
593 matches!(self, Query::One(s) if *s == text)
594 }
595
596 /// `route.query.q === undefined`.
597 #[must_use]
598 pub fn is_undefined(&self) -> bool {
599 matches!(self, Query::Absent)
600 }
601
602 /// `Array.isArray(route.query.q)`.
603 #[must_use]
604 pub fn is_array(&self) -> bool {
605 matches!(self, Query::Many(_))
606 }
607
608 /// `route.query.q ?? fallback`: the fallback for `undefined` and `null`.
609 ///
610 /// # Example
611 ///
612 /// ```
613 /// # use ferrovue_router as ferrovue;
614 /// use ferrovue::Query;
615 ///
616 /// assert_eq!(Query::Absent.or("none"), Query::One("none"));
617 /// assert_eq!(Query::One("").or("none"), Query::One("")); // `??` keeps the empty string
618 /// ```
619 #[must_use]
620 pub fn or<'a>(self, fallback: &'a str) -> Query<'a>
621 where
622 'r: 'a,
623 {
624 match self {
625 Query::Absent | Query::Null => Query::One(fallback),
626 other => other,
627 }
628 }
629
630 /// The single string, when the value is one: `typeof route.query.q === "string"`, which narrows
631 /// it to what an attribute can be bound to. `null`, `undefined` and an array are `None`.
632 ///
633 /// # Example
634 ///
635 /// ```
636 /// # use ferrovue_router as ferrovue;
637 /// use ferrovue::Query;
638 ///
639 /// assert_eq!(Query::One("rust").attr_value(), Some("rust"));
640 /// assert_eq!(Query::Null.attr_value(), None);
641 /// ```
642 #[must_use]
643 pub fn attr_value(&self) -> Option<&'r str> {
644 match self {
645 Query::One(s) => Some(s),
646 _ => None,
647 }
648 }
649}
650
651fn parse_query(search: &str) -> Vec<(String, Vec<Option<String>>)> {
652 let mut query: Vec<(String, Vec<Option<String>>)> = Vec::new();
653 if search.is_empty() {
654 return query;
655 }
656 for pair in search.split('&') {
657 let pair = pair.replace('+', " ");
658 let (key, value) = match pair.find('=') {
659 Some(i) => (decode(&pair[..i]), Some(decode(&pair[i + 1..]))),
660 None => (decode(&pair), None),
661 };
662 match query.iter_mut().find(|(k, _)| *k == key) {
663 Some((_, values)) => values.push(value),
664 None => query.push((key, vec![value])),
665 }
666 }
667 query
668}
669
670/// A `<RouterLink>`'s `to`, resolved: what [`Route::link`], [`Route::link_named`] and
671/// [`Route::link_path`] return.
672///
673/// The `href` is not escaped.
674///
675/// # Example
676///
677/// ```
678/// # use ferrovue_router as ferrovue;
679/// let router = ferrovue::Router::new(&["/", "/blog/:slug"]);
680/// let link = router.at("/blog/intro").link("/blog/intro#top");
681/// assert_eq!(link.href, "/blog/intro#top");
682/// assert!(link.active && link.exact);
683/// ```
684#[derive(Debug, Clone, PartialEq, Eq)]
685#[non_exhaustive]
686pub struct Link {
687 /// What the anchor's `href` is.
688 pub href: String,
689 /// `isActive`: it points at the route the reader is on, or one the reader's route is nested in,
690 /// with parameters the reader's location has too. The `router-link-active` class.
691 pub active: bool,
692 /// `isExactActive`: it points at exactly the reader's route, with the same parameters. The
693 /// `router-link-exact-active` class, and `aria-current`.
694 pub exact: bool,
695}
696
697/// The location as written, and the route it matched with its parameters; the router it borrows
698/// is left out.
699///
700/// # Example
701///
702/// ```
703/// # use ferrovue_router as ferrovue;
704/// let router = ferrovue::Router::named(&[("/blog/:slug", Some("post"))]);
705/// assert_eq!(
706/// format!("{:?}", router.at("/blog/intro?sort=new#comments")),
707/// r#"Route { full_path: "/blog/intro?sort=new#comments", name: Some("post"), params: {"slug": "intro"}, .. }"#
708/// );
709/// assert_eq!(
710/// format!("{:?}", router.at("/nowhere")),
711/// r#"Route { full_path: "/nowhere", name: None, params: {}, .. }"#
712/// );
713/// ```
714impl std::fmt::Debug for Route<'_> {
715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716 let params = match &self.matched {
717 Some((i, values)) => self.router.params(*i, values),
718 None => Vec::new(),
719 };
720 f.debug_struct("Route")
721 .field("full_path", &self.full_path)
722 .field("name", &self.name())
723 .field("params", &DebugMap(params.into_iter()))
724 .finish_non_exhaustive()
725 }
726}
727
728impl Route<'_> {
729 /// The location's path, without its query or hash.
730 #[must_use]
731 pub fn path(&self) -> &str {
732 &self.path
733 }
734
735 /// `route.hash`: the location's hash, with its `#`, decoded; empty when there is none.
736 #[must_use]
737 pub fn hash(&self) -> &str {
738 &self.hash
739 }
740
741 /// `route.query.<key>`.
742 #[must_use]
743 pub fn query(&self, key: &str) -> Query<'_> {
744 match self.query.iter().find(|(k, _)| k == key) {
745 None => Query::Absent,
746 Some((_, values)) => match values.as_slice() {
747 [None] => Query::Null,
748 [Some(v)] => Query::One(v),
749 many => Query::Many(many),
750 },
751 }
752 }
753
754 /// `route.fullPath`: the path, then the query and hash as they were written.
755 #[must_use]
756 pub fn full_path(&self) -> &str {
757 &self.full_path
758 }
759
760 /// `route.name`: the name of the route the location matched, if it has one.
761 #[must_use]
762 pub fn name(&self) -> Option<&str> {
763 let (i, _) = self.matched.as_ref()?;
764 self.router.routes[*i].name.as_deref()
765 }
766
767 /// `route.params.<name>`: that parameter of the route the location matched, decoded; `None`
768 /// when the route has no such parameter, an optional one the location leaves out, or the
769 /// location matched no route.
770 #[must_use]
771 pub fn param(&self, name: &str) -> Option<&str> {
772 let (i, values) = self.matched.as_ref()?;
773 let at = self.router.routes[*i]
774 .tokens
775 .iter()
776 .filter_map(|t| match t {
777 Token::Param { name, .. } => Some(name.as_str()),
778 Token::Static(_) => None,
779 })
780 .position(|n| n == name)?;
781 values.get(at)?.as_deref()
782 }
783
784 /// Resolve a link from here: a string `to`, absolute or relative to the current path, with any
785 /// query and hash, which are kept as written.
786 ///
787 /// # Example
788 ///
789 /// ```
790 /// # use ferrovue_router as ferrovue;
791 /// let router = ferrovue::Router::new(&["/", "/blog/:slug"]);
792 /// let here = router.at("/blog/intro");
793 /// let link = here.link("/blog/intro?sort=new");
794 /// assert_eq!(link.href, "/blog/intro?sort=new");
795 /// assert!(link.active, "the query does not change which route it is");
796 /// assert!(!here.link("/").active);
797 /// ```
798 #[must_use]
799 pub fn link(&self, to: &str) -> Link {
800 let (path, full) = parse_url(to, &self.path);
801 let (active, exact) = self.state(self.router.matched(&path));
802 Link {
803 href: format!("{}{full}", self.router.base),
804 active,
805 exact,
806 }
807 }
808
809 fn state(&self, target: Option<(usize, Values)>) -> (bool, bool) {
810 let (Some((t, link_values)), Some((c, here_values))) = (target, self.matched.as_ref())
811 else {
812 return (false, false);
813 };
814 let router = self.router;
815 let link_chain = router.chain(t);
816 let here_chain = router.chain(*c);
817 let routed = *link_chain.last().expect("a chain holds its route");
818 let mut index = here_chain.iter().position(|&r| r == routed);
819 if index.is_none() && link_chain.len() > 1 {
820 let parent = link_chain[link_chain.len() - 2];
821 let parent_path = &router.routes[parent].path;
822 let here_last = *here_chain.last().expect("a chain holds its route");
823 if router.routes[routed].path == *parent_path
824 && router.routes[here_last].path != *parent_path
825 {
826 index = here_chain.iter().position(|&r| r == parent);
827 }
828 }
829 let Some(index) = index else {
830 return (false, false);
831 };
832 let link_params = router.params(t, &link_values);
833 let here_params = router.params(*c, here_values);
834 let includes = link_params
835 .iter()
836 .all(|(k, v)| here_params.iter().any(|(hk, hv)| hk == k && hv == v));
837 let same = includes && link_params.len() == here_params.len();
838 (includes, index == here_chain.len() - 1 && same)
839 }
840
841 /// Resolve `{ name, params, query, hash }`: the named route's path with each parameter
842 /// encoded, then `search` (a query [`query_into`] built, without its `?`) and `hash`.
843 ///
844 /// An optional parameter left out of `params` is left out of the path, unless the route is
845 /// nested in one that has it optional and the current location gives it, which vue-router
846 /// then keeps.
847 ///
848 /// # Panics
849 ///
850 /// On a name no route has. The compiler checks the names a template uses against the routes.
851 /// A debug build also panics on a required parameter that is missing or empty, as vue-router
852 /// throws on one; a release build writes the link without it.
853 ///
854 /// # Example
855 ///
856 /// ```
857 /// # use ferrovue_router as ferrovue;
858 /// let router = ferrovue::Router::named(&[("/", Some("home")), ("/books/:id", Some("book"))]);
859 /// let route = router.at("/books/dune");
860 ///
861 /// // <RouterLink :to="{ name: 'book', params: { id: 'dune' }, query: { tab: 'reviews' }, hash: '#top' }">
862 /// let mut search = String::new();
863 /// ferrovue::query_into(&mut search, "tab", "reviews");
864 /// let link = route.link_named("book", &[("id", "dune")], &search, "#top");
865 /// assert_eq!(link.href, "/books/dune?tab=reviews#top");
866 /// assert!(link.active && link.exact);
867 ///
868 /// // Parameters are encoded as vue-router encodes them.
869 /// assert_eq!(route.link_named("book", &[("id", "a/b c")], "", "").href, "/books/a%2Fb%20c");
870 /// ```
871 #[must_use]
872 pub fn link_named(
873 &self,
874 name: &str,
875 params: &[(&str, &str)],
876 search: &str,
877 hash: &str,
878 ) -> Link {
879 let i = self
880 .router
881 .routes
882 .iter()
883 .position(|r| r.name.as_deref() == Some(name))
884 .unwrap_or_else(|| panic!("no route is called {name:?}"));
885 let router = self.router;
886 let route = &router.routes[i];
887 let current = match &self.matched {
888 Some((c, values)) => router.params(*c, values),
889 None => Vec::new(),
890 };
891 let inherited: Vec<&str> = route.parent.map_or_else(Vec::new, |p| {
892 router.routes[p]
893 .tokens
894 .iter()
895 .filter_map(|t| match t {
896 Token::Param {
897 name,
898 optional: true,
899 ..
900 } => Some(name.as_str()),
901 _ => None,
902 })
903 .collect()
904 });
905 let mut path = String::new();
906 let mut values = Vec::new();
907 for token in &route.tokens {
908 path.push('/');
909 match token {
910 Token::Static(text) => path.push_str(text),
911 Token::Param { name, optional, .. } => {
912 let given = params.iter().find(|(k, _)| k == name).map(|(_, v)| *v);
913 let value = given.or_else(|| {
914 let pick = !optional || inherited.contains(&name.as_str());
915 pick.then(|| current.iter().find(|(k, _)| k == name).map(|(_, v)| *v))
916 .flatten()
917 });
918 let text = value.unwrap_or("");
919 if text.is_empty() && *optional {
920 path.pop();
921 }
922 debug_assert!(
923 *optional || !text.is_empty(),
924 "missing required param {name:?} for the route called {:?}",
925 route.name
926 );
927 let encoded = encode_param(text);
928 values.push(value.map(|_| decode(&encoded)));
929 path.push_str(&encoded);
930 }
931 }
932 }
933 if path.is_empty() {
934 path.push('/');
935 }
936 let (active, exact) = self.state(Some((i, values)));
937 Link {
938 href: self.href(&path, search, hash),
939 active,
940 exact,
941 }
942 }
943
944 /// Resolve `{ path, query, hash }`: the path resolved from here, as a string `to` would be,
945 /// then `search` and `hash`.
946 ///
947 /// # Example
948 ///
949 /// ```
950 /// # use ferrovue_router as ferrovue;
951 /// let router = ferrovue::Router::new(&["/", "/search"]);
952 /// let route = router.at("/");
953 ///
954 /// // <RouterLink :to="{ path: '/search', query: { q: 'a b' } }">
955 /// let mut search = String::new();
956 /// ferrovue::query_into(&mut search, "q", "a b");
957 /// let link = route.link_path("/search", &search, "");
958 /// assert_eq!(link.href, "/search?q=a+b");
959 /// assert!(!link.active);
960 /// ```
961 #[must_use]
962 pub fn link_path(&self, path: &str, search: &str, hash: &str) -> Link {
963 let (path, _) = parse_url(path, &self.path);
964 let (active, exact) = self.state(self.router.matched(&path));
965 Link {
966 href: self.href(&path, search, hash),
967 active,
968 exact,
969 }
970 }
971
972 fn href(&self, path: &str, search: &str, hash: &str) -> String {
973 let mut href = format!("{}{path}", self.router.base);
974 if !search.is_empty() {
975 href.push('?');
976 href.push_str(search);
977 }
978 href.push_str(&encode_url(hash, Part::Hash));
979 href
980 }
981}
982
983/// `stringifyQuery`, one key and value at a time: `key=value`, each encoded as vue-router encodes
984/// them, after a `&` when `search` already holds a pair.
985///
986/// # Example
987///
988/// ```
989/// # use ferrovue_router as ferrovue;
990/// let mut search = String::new();
991/// ferrovue::query_into(&mut search, "q", "a b&c");
992/// ferrovue::query_into(&mut search, "page", "2");
993/// assert_eq!(search, "q=a+b%26c&page=2");
994/// ```
995pub fn query_into(search: &mut String, key: &str, value: &str) {
996 if !search.is_empty() {
997 search.push('&');
998 }
999 search.push_str(&encode_url(key, Part::QueryKey));
1000 search.push('=');
1001 search.push_str(&encode_url(value, Part::QueryValue));
1002}
1003
1004#[derive(Clone, Copy, PartialEq)]
1005enum Part {
1006 Param,
1007 QueryValue,
1008 QueryKey,
1009 Hash,
1010}
1011
1012fn encode_param(text: &str) -> String {
1013 encode_url(text, Part::Param)
1014}
1015
1016fn encode_url(text: &str, part: Part) -> String {
1017 use std::fmt::Write;
1018 let mut out = String::with_capacity(text.len());
1019 for c in text.chars() {
1020 let uri = c.is_ascii_alphanumeric() || ";,/?:@&=+$-_.!~*'()#|[]".contains(c);
1021 let keep = match (part, c) {
1022 (Part::Param, '#' | '?' | '/') => false,
1023 (Part::QueryValue | Part::QueryKey, '+' | '#' | '&') => false,
1024 (Part::QueryKey, '=') => false,
1025 (Part::QueryValue | Part::QueryKey, ' ') => {
1026 out.push('+');
1027 continue;
1028 }
1029 (Part::QueryValue | Part::QueryKey, '`' | '{' | '}' | '^') => true,
1030 (Part::Hash, '{' | '}' | '^') => true,
1031 _ => uri,
1032 };
1033 if keep {
1034 out.push(c);
1035 } else {
1036 let mut buf = [0; 4];
1037 for b in c.encode_utf8(&mut buf).bytes() {
1038 let _ = write!(out, "%{b:02X}");
1039 }
1040 }
1041 }
1042 out
1043}
1044
1045impl Pattern {
1046 fn new(path: &str, name: Option<String>) -> Pattern {
1047 let Some(rest) = path.strip_prefix('/') else {
1048 panic!("a route path starts with `/`: {path:?}");
1049 };
1050 if rest.is_empty() {
1051 return Pattern {
1052 path: path.to_owned(),
1053 parent: None,
1054 tokens: Vec::new(),
1055 score: vec![ROOT],
1056 name,
1057 matchable: true,
1058 };
1059 }
1060 let segments: Vec<&str> = rest.split('/').collect();
1061 let mut tokens = Vec::new();
1062 let mut score = Vec::new();
1063 for (i, seg) in segments.iter().enumerate() {
1064 if let Some(param) = seg.strip_prefix(':') {
1065 let (param, optional) = match param.strip_suffix('?') {
1066 Some(param) => (param, true),
1067 None => (param, false),
1068 };
1069 let (name, wildcard) = match param.strip_suffix("(.*)") {
1070 Some(name) => (name, true),
1071 None => (param, false),
1072 };
1073 let named = !name.is_empty()
1074 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
1075 && !(wildcard && optional);
1076 assert!(
1077 named,
1078 "a parameter is `:name`, `:name?` or `:name(.*)`: {path:?}"
1079 );
1080 assert!(
1081 !wildcard || i == segments.len() - 1,
1082 "`(.*)` is supported on the last segment only: {path:?}"
1083 );
1084 tokens.push(Token::Param {
1085 name: name.to_owned(),
1086 wildcard,
1087 optional,
1088 });
1089 score.push(match (wildcard, optional) {
1090 (true, _) => WILDCARD,
1091 (false, true) => OPTIONAL,
1092 (false, false) => PARAM,
1093 });
1094 } else {
1095 let plain = !seg.is_empty()
1096 && seg.is_ascii()
1097 && !seg.contains([':', '(', ')', '*', '?', '+', '\\']);
1098 assert!(plain, "a static segment is plain ASCII text: {path:?}");
1099 tokens.push(Token::Static((*seg).to_owned()));
1100 score.push(STATIC);
1101 }
1102 }
1103 Pattern {
1104 path: path.to_owned(),
1105 parent: None,
1106 tokens,
1107 score,
1108 name,
1109 matchable: true,
1110 }
1111 }
1112
1113 fn parse(&self, path: &str) -> Option<Values> {
1114 let mut params = Vec::new();
1115 self.parse_from(&self.tokens, path, &mut params)
1116 .then_some(params)
1117 }
1118
1119 fn parse_from(&self, tokens: &[Token], rest: &str, params: &mut Values) -> bool {
1120 let Some((token, after)) = tokens.split_first() else {
1121 return matches!(rest, "" | "/");
1122 };
1123 match token {
1124 Token::Static(text) => {
1125 let Some(rest) = rest.strip_prefix('/') else {
1126 return false;
1127 };
1128 match rest.get(..text.len()) {
1129 Some(head) if head.eq_ignore_ascii_case(text) => {
1130 self.parse_from(after, &rest[text.len()..], params)
1131 }
1132 _ => false,
1133 }
1134 }
1135 Token::Param {
1136 wildcard: false,
1137 optional,
1138 ..
1139 } => {
1140 if let Some(inner) = rest.strip_prefix('/') {
1141 let end = inner.find('/').unwrap_or(inner.len());
1142 if end > 0 {
1143 params.push(Some(decode(&inner[..end])));
1144 if self.parse_from(after, &inner[end..], params) {
1145 return true;
1146 }
1147 params.pop();
1148 }
1149 }
1150 if *optional {
1151 params.push(None);
1152 if self.parse_from(after, rest, params) {
1153 return true;
1154 }
1155 params.pop();
1156 }
1157 false
1158 }
1159 Token::Param { wildcard: true, .. } => {
1160 let Some(rest) = rest.strip_prefix('/') else {
1161 return false;
1162 };
1163 if rest.contains(['\n', '\r', '\u{2028}', '\u{2029}']) {
1164 return false;
1165 }
1166 params.push(Some(decode(rest)));
1167 self.parse_from(after, "", params)
1168 }
1169 }
1170 }
1171}
1172
1173fn compare(a: &[i32], b: &[i32]) -> std::cmp::Ordering {
1174 for (x, y) in a.iter().zip(b) {
1175 if x != y {
1176 return y.cmp(x);
1177 }
1178 }
1179 b.len().cmp(&a.len())
1180}
1181
1182fn parse_url(location: &str, current: &str) -> (String, String) {
1183 let hash_pos = location.find('#');
1184 let search_pos = location
1185 .find('?')
1186 .filter(|&s| hash_pos.is_none_or(|h| s < h));
1187 let mut path: Option<&str> = None;
1188 let mut search = "";
1189 let mut hash = "";
1190 if let Some(s) = search_pos {
1191 path = Some(&location[..s]);
1192 let end = hash_pos.filter(|&h| h > 0).unwrap_or(location.len());
1193 search = &location[s..end];
1194 }
1195 if let Some(h) = hash_pos {
1196 path = Some(path.filter(|p| !p.is_empty()).unwrap_or(&location[..h]));
1197 hash = &location[h..];
1198 }
1199 let path = resolve_relative_path(path.unwrap_or(location), current);
1200 let full = format!("{path}{search}{hash}");
1201 (path, full)
1202}
1203
1204fn resolve_relative_path(to: &str, from: &str) -> String {
1205 if to.starts_with('/') {
1206 return to.to_owned();
1207 }
1208 if to.is_empty() {
1209 return from.to_owned();
1210 }
1211 let from_segments: Vec<&str> = from.split('/').collect();
1212 let mut to_segments: Vec<&str> = to.split('/').collect();
1213 if matches!(to_segments.last(), Some(&("." | ".."))) {
1214 to_segments.push("");
1215 }
1216 let mut position = from_segments.len() - 1;
1217 let mut to_position = 0;
1218 while to_position < to_segments.len() {
1219 match to_segments[to_position] {
1220 "." => {}
1221 ".." => {
1222 if position > 1 {
1223 position -= 1;
1224 }
1225 }
1226 _ => break,
1227 }
1228 to_position += 1;
1229 }
1230 format!(
1231 "{}/{}",
1232 from_segments[..position].join("/"),
1233 to_segments[to_position..].join("/")
1234 )
1235}
1236
1237fn decode(text: &str) -> String {
1238 if !text.contains('%') {
1239 return text.to_owned();
1240 }
1241 let bytes = text.as_bytes();
1242 let mut out = Vec::with_capacity(bytes.len());
1243 let mut i = 0;
1244 while i < bytes.len() {
1245 if bytes[i] == b'%' {
1246 let hex = |b: u8| (b as char).to_digit(16);
1247 match (
1248 bytes.get(i + 1).and_then(|&b| hex(b)),
1249 bytes.get(i + 2).and_then(|&b| hex(b)),
1250 ) {
1251 (Some(hi), Some(lo)) => {
1252 out.push((hi * 16 + lo) as u8);
1253 i += 3;
1254 continue;
1255 }
1256 _ => return text.to_owned(),
1257 }
1258 }
1259 out.push(bytes[i]);
1260 i += 1;
1261 }
1262 String::from_utf8(out).unwrap_or_else(|_| text.to_owned())
1263}
1264
1265#[cfg(test)]
1266mod tests;