1#![cfg_attr(not(feature = "std"), no_std)]
29
30extern crate alloc;
31
32mod error;
33mod parser;
34
35pub use error::ParseError;
36
37use alloc::borrow::Cow;
38use alloc::boxed::Box;
39
40use alloc::string::{String, ToString};
41use core::fmt;
42use nami_core::Signal;
43use waterui_str::Str;
44
45#[cfg(feature = "std")]
46use std::path::{Path, PathBuf};
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58struct Span {
59 start: u16,
60 end: u16,
61}
62
63impl Span {
64 const NONE: Self = Self {
66 start: 0xFFFF,
67 end: 0xFFFF,
68 };
69
70 #[inline]
72 const fn is_present(self) -> bool {
73 self.start != 0xFFFF
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
79enum ParsedComponents {
80 Web(WebComponents),
81 Local(LocalComponents),
82 Data(DataComponents),
83 Blob(BlobComponents),
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
88struct WebComponents {
89 scheme: Span,
91 authority: Span,
93 host: Span,
95 port: Span,
97 path: Span,
99 query: Span,
101 fragment: Span,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
107struct LocalComponents {
108 path: Span,
110 is_absolute: bool,
112 is_windows: bool,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
118struct DataComponents {
119 mime_type: Span,
121 encoding: Span,
123 data: Span,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
129struct BlobComponents {
130 identifier: Span,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
161pub struct Url {
162 inner: Str,
164 components: ParsedComponents,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
170pub enum UrlKind {
171 Web,
173 Local,
175 Data,
177 Blob,
179}
180
181impl Url {
182 #[must_use]
211 pub const fn new(url: &'static str) -> Self {
212 Self {
213 inner: Str::from_static(url),
214 components: parser::parse_url(url.as_bytes()),
215 }
216 }
217
218 pub fn parse(url: impl AsRef<str>) -> Option<Self> {
232 url.as_ref().parse::<Self>().ok().filter(Self::is_web)
233 }
234
235 #[cfg(feature = "std")]
249 pub fn from_file_path(path: impl AsRef<Path>) -> Self {
250 let path_str = path.as_ref().display().to_string();
251 let inner = Str::from(path_str);
252 let components = parser::parse_url(inner.as_bytes());
253 Self { inner, components }
254 }
255
256 pub fn from_file_path_str(path: impl Into<Str>) -> Self {
258 let inner = path.into();
259 let components = parser::parse_url(inner.as_bytes());
260 Self { inner, components }
261 }
262
263 #[must_use]
274 pub fn from_data(mime_type: &str, data: &[u8]) -> Self {
275 use alloc::format;
276
277 let encoded = base64_encode(data);
279 let url_str = format!("data:{mime_type};base64,{encoded}");
280
281 let inner = Str::from(url_str);
282 let components = parser::parse_url(inner.as_bytes());
283 Self { inner, components }
284 }
285
286 #[inline]
291 fn slice(&self, span: Span) -> &str {
292 if !span.is_present() {
293 return "";
294 }
295 let bytes = self.inner.as_bytes();
296 let start = span.start as usize;
297 let end = span.end as usize;
298 unsafe { core::str::from_utf8_unchecked(&bytes[start..end]) }
300 }
301
302 #[must_use]
304 pub const fn is_web(&self) -> bool {
305 matches!(self.components, ParsedComponents::Web(_))
306 }
307
308 #[must_use]
310 pub const fn is_local(&self) -> bool {
311 matches!(self.components, ParsedComponents::Local(_))
312 }
313
314 #[must_use]
316 pub const fn is_data(&self) -> bool {
317 matches!(self.components, ParsedComponents::Data(_))
318 }
319
320 #[must_use]
322 pub const fn is_blob(&self) -> bool {
323 matches!(self.components, ParsedComponents::Blob(_))
324 }
325
326 #[must_use]
328 pub const fn is_absolute(&self) -> bool {
329 match self.components {
330 ParsedComponents::Web(_) | ParsedComponents::Data(_) | ParsedComponents::Blob(_) => {
331 true
332 }
333 ParsedComponents::Local(local) => local.is_absolute,
334 }
335 }
336
337 #[must_use]
339 pub fn inner(&self) -> Str {
340 self.inner.clone()
341 }
342
343 #[must_use]
345 pub const fn is_relative(&self) -> bool {
346 !self.is_absolute()
347 }
348
349 #[must_use]
353 pub fn scheme(&self) -> Option<&str> {
354 match self.components {
355 ParsedComponents::Web(web) if web.scheme.is_present() => Some(self.slice(web.scheme)),
356 ParsedComponents::Data(_) => Some("data"),
357 ParsedComponents::Blob(_) => Some("blob"),
358 ParsedComponents::Local(_) => Some("file"),
359 ParsedComponents::Web(_) => None,
360 }
361 }
362
363 #[must_use]
367 pub fn host(&self) -> Option<&str> {
368 match self.components {
369 ParsedComponents::Web(web) if web.host.is_present() => Some(self.slice(web.host)),
370 _ => None,
371 }
372 }
373
374 #[must_use]
378 pub fn path(&self) -> &str {
379 match self.components {
380 ParsedComponents::Web(web) if web.path.is_present() => self.slice(web.path),
381 ParsedComponents::Web(_) => "/", ParsedComponents::Local(local) => self.slice(local.path),
383 ParsedComponents::Data(_) | ParsedComponents::Blob(_) => "",
384 }
385 }
386
387 #[must_use]
392 pub fn port(&self) -> Option<u16> {
393 match self.components {
394 ParsedComponents::Web(web) if web.port.is_present() => {
395 self.slice(web.port).parse().ok()
396 }
397 _ => None,
398 }
399 }
400
401 #[must_use]
414 pub fn query(&self) -> Option<&str> {
415 match self.components {
416 ParsedComponents::Web(web) if web.query.is_present() => Some(self.slice(web.query)),
417 _ => None,
418 }
419 }
420
421 #[must_use]
434 pub fn fragment(&self) -> Option<&str> {
435 match self.components {
436 ParsedComponents::Web(web) if web.fragment.is_present() => {
437 Some(self.slice(web.fragment))
438 }
439 _ => None,
440 }
441 }
442
443 #[must_use]
447 pub fn authority(&self) -> Option<&str> {
448 match self.components {
449 ParsedComponents::Web(web) if web.authority.is_present() => {
450 Some(self.slice(web.authority))
451 }
452 _ => None,
453 }
454 }
455
456 #[must_use]
458 pub fn extension(&self) -> Option<&str> {
459 let path = self.path();
460 let name = path.rsplit('/').next()?;
461 let ext_start = name.rfind('.')?;
462
463 if ext_start == 0 || ext_start == name.len() - 1 {
464 None
465 } else {
466 Some(&name[ext_start + 1..])
467 }
468 }
469
470 #[must_use]
472 pub fn filename(&self) -> Option<&str> {
473 let path = self.path();
474 path.rsplit('/').next().filter(|s| !s.is_empty())
475 }
476
477 #[must_use]
489 pub fn join(&self, path: &str) -> Self {
490 if path.is_empty() {
491 return self.clone();
492 }
493
494 if matches!(parser::parse_url(path.as_bytes()), ParsedComponents::Web(_))
496 || path.starts_with('/')
497 {
498 return path
499 .parse()
500 .unwrap_or_else(|_| Self::from_file_path_str(path.to_string()));
501 }
502
503 match self.components {
504 ParsedComponents::Web(_) => {
505 let base = self.inner.as_str();
506 let mut result = String::from(base);
507
508 if !result.ends_with('/') {
510 if let Some(scheme_end) = result.find("://") {
512 let after_scheme = &result[scheme_end + 3..];
513 if let Some(path_start) = after_scheme.find('/') {
514 let full_path_start = scheme_end + 3 + path_start;
516 let after_slash = &result[full_path_start + 1..];
517 if after_slash.contains('.')
518 || after_slash.contains('?')
519 || after_slash.contains('#')
520 {
521 if let Some(last_slash) = result.rfind('/') {
523 result.truncate(last_slash + 1);
524 }
525 } else {
526 result.push('/');
527 }
528 } else {
529 result.push('/');
531 }
532 } else {
533 result.push('/');
534 }
535 }
536
537 result.push_str(path);
538 result
539 .parse()
540 .unwrap_or_else(|_| Self::from_file_path_str(result))
541 }
542 ParsedComponents::Local(_) => {
543 #[cfg(feature = "std")]
544 {
545 let base_path = PathBuf::from(self.inner.as_str());
546 let joined = if base_path.is_file() {
547 base_path.parent().unwrap_or(&base_path).join(path)
548 } else {
549 base_path.join(path)
550 };
551 Self::from_file_path(joined)
552 }
553 #[cfg(not(feature = "std"))]
554 {
555 let mut result = String::from(self.inner.as_str());
556 if !result.ends_with('/') && !result.ends_with('\\') {
557 result.push('/');
558 }
559 result.push_str(path);
560 Self::from_file_path_str(result)
561 }
562 }
563 _ => self.clone(),
564 }
565 }
566
567 #[must_use]
571 pub fn fetch(&self) -> Fetched {
572 Fetched { url: self.clone() }
573 }
574
575 #[must_use]
577 pub const fn as_str(&self) -> &str {
578 self.inner.as_str()
579 }
580
581 #[must_use]
583 pub fn into_string(self) -> String {
584 String::from(self.inner)
585 }
586
587 #[cfg(feature = "std")]
589 #[must_use]
590 pub fn to_file_path(&self) -> Option<PathBuf> {
591 if self.is_local() {
592 Some(PathBuf::from(self.inner.as_str()))
593 } else {
594 None
595 }
596 }
597}
598
599impl fmt::Display for Url {
600 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601 write!(f, "{}", self.inner)
602 }
603}
604
605impl AsRef<str> for Url {
606 fn as_ref(&self) -> &str {
607 self.as_str()
608 }
609}
610
611impl core::str::FromStr for Url {
612 type Err = ParseError;
613
614 fn from_str(s: &str) -> Result<Self, Self::Err> {
615 if s.is_empty() {
616 return Err(ParseError::empty());
617 }
618
619 Ok(Self {
620 inner: Str::from(s.to_string()),
621 components: parser::parse_url(s.as_bytes()),
622 })
623 }
624}
625
626impl From<&'static str> for Url {
627 fn from(value: &'static str) -> Self {
628 Self::new(value)
629 }
630}
631
632impl From<String> for Url {
633 fn from(value: String) -> Self {
634 value
636 .as_str()
637 .parse()
638 .unwrap_or_else(|_| Self::from_file_path_str(value))
639 }
640}
641
642impl From<Str> for Url {
643 fn from(value: Str) -> Self {
644 value
646 .as_str()
647 .parse()
648 .unwrap_or_else(|_| Self::from_file_path_str(value))
649 }
650}
651
652impl<'a> From<Cow<'a, str>> for Url {
653 fn from(value: Cow<'a, str>) -> Self {
654 match value {
655 Cow::Borrowed(s) => s
656 .parse()
657 .unwrap_or_else(|_| Self::from_file_path_str(s.to_string())),
658 Cow::Owned(s) => s.parse().unwrap_or_else(|_| Self::from_file_path_str(s)),
659 }
660 }
661}
662
663impl From<Url> for Str {
664 fn from(url: Url) -> Self {
665 url.inner
666 }
667}
668
669nami_core::impl_constant!(Url);
672
673#[derive(Debug, Clone)]
675pub struct Fetched {
676 url: Url,
677}
678
679impl Signal for Fetched {
680 type Output = Option<Url>;
681 type Guard = nami_core::watcher::BoxWatcherGuard;
682
683 fn get(&self) -> Self::Output {
684 Some(self.url.clone())
686 }
687
688 fn watch(
689 &self,
690 _watcher: impl Fn(nami_core::watcher::Context<Self::Output>) + 'static,
691 ) -> Self::Guard {
692 Box::new(())
694 }
695}
696
697fn base64_encode(data: &[u8]) -> String {
699 use alloc::vec::Vec;
700
701 const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
702
703 let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4);
704
705 for chunk in data.chunks(3) {
706 let mut buf = [0u8; 3];
707 for (i, &byte) in chunk.iter().enumerate() {
708 buf[i] = byte;
709 }
710
711 result.push(TABLE[(buf[0] >> 2) as usize]);
712 result.push(TABLE[(((buf[0] & 0x03) << 4) | (buf[1] >> 4)) as usize]);
713
714 if chunk.len() > 1 {
715 result.push(TABLE[(((buf[1] & 0x0f) << 2) | (buf[2] >> 6)) as usize]);
716 } else {
717 result.push(b'=');
718 }
719
720 if chunk.len() > 2 {
721 result.push(TABLE[(buf[2] & 0x3f) as usize]);
722 } else {
723 result.push(b'=');
724 }
725 }
726
727 String::from_utf8(result).expect("base64 encoding should produce valid utf8")
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
735 fn test_const_url_creation() {
736 const WEB: Url = Url::new("https://example.com");
737 const LOCAL: Url = Url::new("/path/to/file");
738 const DATA: Url = Url::new("data:text/plain,hello");
739 const BLOB: Url = Url::new("blob:https://example.com/uuid");
740
741 assert!(WEB.is_web());
742 assert!(LOCAL.is_local());
743 assert!(DATA.is_data());
744 assert!(BLOB.is_blob());
745 }
746
747 #[test]
748 fn test_fromstr_valid_web_urls() {
749 let urls = [
750 "http://example.com",
751 "https://example.com:443/path",
752 "ftp://server.com/file",
753 "ws://example.com",
754 "wss://example.com",
755 ];
756
757 for url_str in urls {
758 let url: Url = url_str.parse().unwrap();
759 assert!(url.is_web(), "Failed for: {url_str}");
760 }
761 }
762
763 #[test]
764 fn test_fromstr_local_paths() {
765 let paths = [
766 "/absolute/path",
767 "./relative",
768 "file.txt",
769 "C:\\Windows\\file.txt",
770 ];
771
772 for path in paths {
773 let url: Url = path.parse().unwrap();
774 assert!(url.is_local(), "Failed for: {path}");
775 }
776 }
777
778 #[test]
779 fn test_fromstr_data_urls() {
780 let url: Url = "data:text/plain,hello".parse().unwrap();
781 assert!(url.is_data());
782 }
783
784 #[test]
785 fn test_fromstr_blob_urls() {
786 let url: Url = "blob:https://example.com/uuid".parse().unwrap();
787 assert!(url.is_blob());
788 }
789
790 #[test]
791 fn test_fromstr_empty_error() {
792 let result: Result<Url, _> = "".parse();
793 assert!(result.is_err());
794 }
795
796 #[test]
797 fn test_web_url_detection() {
798 let url = Url::new("https://example.com/image.jpg");
799 assert!(url.is_web());
800 assert!(!url.is_local());
801 assert_eq!(url.scheme(), Some("https"));
802 assert_eq!(url.host(), Some("example.com"));
803 assert_eq!(url.path(), "/image.jpg");
804 }
805
806 #[test]
807 fn test_local_path_detection() {
808 let url1 = Url::new("/absolute/path/file.txt");
809 assert!(url1.is_local());
810 assert!(!url1.is_web());
811 assert!(url1.is_absolute());
812
813 let url2 = Url::new("./relative/path.txt");
814 assert!(url2.is_local());
815 assert!(url2.is_relative());
816
817 let url3 = Url::new("file.txt");
818 assert!(url3.is_local());
819 assert!(url3.is_relative());
820 }
821
822 #[test]
823 fn test_parse_valid_urls() {
824 assert!(Url::parse("http://localhost:3000").is_some());
825 assert!(Url::parse("https://example.com/path?query=1").is_some());
826 assert!(Url::parse("ftp://server.com/file").is_some());
827
828 assert!(Url::parse("/local/path").is_none());
829 assert!(Url::parse("relative/path").is_none());
830 }
831
832 #[test]
833 fn test_data_url() {
834 let url = Url::from_data("image/png", b"test");
835 assert!(url.is_data());
836 assert!(url.as_str().starts_with("data:image/png;base64,"));
837 }
838
839 #[test]
840 fn test_extension_extraction() {
841 let url1 = Url::new("https://example.com/image.jpg");
842 assert_eq!(url1.extension(), Some("jpg"));
843
844 let url2 = Url::new("/path/to/file.tar.gz");
845 assert_eq!(url2.extension(), Some("gz"));
846
847 let url3 = Url::new("https://example.com/noext");
848 assert_eq!(url3.extension(), None);
849
850 let url4 = Url::new("https://example.com/.hidden");
851 assert_eq!(url4.extension(), None);
852 }
853
854 #[test]
855 fn test_filename_extraction() {
856 let url1 = Url::new("https://example.com/path/image.jpg");
857 assert_eq!(url1.filename(), Some("image.jpg"));
858
859 let url2 = Url::new("/path/to/file.txt");
860 assert_eq!(url2.filename(), Some("file.txt"));
861
862 let url3 = Url::new("https://example.com/");
863 assert_eq!(url3.filename(), None);
864 }
865
866 #[test]
867 fn test_url_joining() {
868 let base1 = Url::new("https://example.com/images/");
869 let joined1 = base1.join("photo.jpg");
870 assert_eq!(joined1.as_str(), "https://example.com/images/photo.jpg");
871
872 let base2 = Url::new("https://example.com/images/old.jpg");
873 let joined2 = base2.join("new.jpg");
874 assert_eq!(joined2.as_str(), "https://example.com/images/new.jpg");
875
876 let base3 = Url::new("https://example.com");
877 let joined3 = base3.join("images/photo.jpg");
878 assert_eq!(joined3.as_str(), "https://example.com/images/photo.jpg");
879 }
880
881 #[test]
882 fn test_windows_paths() {
883 let url = Url::new("C:\\Users\\file.txt");
884 assert!(url.is_local());
885 assert!(url.is_absolute());
886 }
887
888 #[test]
889 fn test_blob_url() {
890 let url = Url::new("blob:https://example.com/uuid");
891 assert!(url.is_blob());
892 assert_eq!(url.scheme(), Some("blob"));
893 }
894
895 #[test]
896 fn test_url_host_extraction() {
897 let url1 = Url::new("https://example.com/path");
898 assert_eq!(url1.host(), Some("example.com"));
899
900 let url2 = Url::new("http://localhost:8080/api");
901 assert_eq!(url2.host(), Some("localhost")); assert_eq!(url2.port(), Some(8080)); let url3 = Url::new("https://sub.domain.com");
905 assert_eq!(url3.host(), Some("sub.domain.com"));
906
907 let url4 = Url::new("/local/path");
908 assert_eq!(url4.host(), None);
909 }
910
911 #[test]
912 fn test_complete_url_parsing() {
913 const FULL_URL: Url =
915 Url::new("https://user:pass@example.com:8080/path/to/resource?query=1&foo=bar#section");
916
917 assert_eq!(FULL_URL.scheme(), Some("https"));
918 assert_eq!(FULL_URL.host(), Some("example.com"));
919 assert_eq!(FULL_URL.port(), Some(8080));
920 assert_eq!(FULL_URL.path(), "/path/to/resource");
921 assert_eq!(FULL_URL.query(), Some("query=1&foo=bar"));
922 assert_eq!(FULL_URL.fragment(), Some("section"));
923 assert_eq!(FULL_URL.authority(), Some("user:pass@example.com:8080"));
924 }
925
926 #[test]
927 fn test_minimal_url() {
928 const MIN_URL: Url = Url::new("https://example.com");
929
930 assert_eq!(MIN_URL.scheme(), Some("https"));
931 assert_eq!(MIN_URL.host(), Some("example.com"));
932 assert_eq!(MIN_URL.port(), None);
933 assert_eq!(MIN_URL.path(), "/");
934 assert_eq!(MIN_URL.query(), None);
935 assert_eq!(MIN_URL.fragment(), None);
936 }
937
938 #[test]
939 fn test_ipv6_url() {
940 const IPV6: Url = Url::new("http://[::1]:8080/test");
941 assert_eq!(IPV6.host(), Some("[::1]"));
942 assert_eq!(IPV6.port(), Some(8080));
943 assert_eq!(IPV6.path(), "/test");
944 }
945
946 #[test]
947 fn test_query_and_fragment() {
948 const URL1: Url = Url::new("https://example.com?foo=bar");
949 const URL2: Url = Url::new("https://example.com#section");
950 const URL3: Url = Url::new("https://example.com?foo=bar#section");
951
952 assert_eq!(URL1.query(), Some("foo=bar"));
953 assert_eq!(URL1.fragment(), None);
954
955 assert_eq!(URL2.query(), None);
956 assert_eq!(URL2.fragment(), Some("section"));
957
958 assert_eq!(URL3.query(), Some("foo=bar"));
959 assert_eq!(URL3.fragment(), Some("section"));
960 }
961
962 #[test]
963 fn test_conversions() {
964 let url = Url::new("https://example.com");
965 let as_str: &str = url.as_ref();
966 assert_eq!(as_str, "https://example.com");
967
968 let as_string = url.clone().into_string();
969 assert_eq!(as_string, "https://example.com");
970
971 let from_string = Url::from("test".to_string());
972 assert_eq!(from_string.as_str(), "test");
973 }
974
975 #[test]
976 fn test_base64_encoding() {
977 let encoded = base64_encode(b"hello");
978 assert_eq!(encoded, "aGVsbG8=");
979
980 let encoded2 = base64_encode(b"hi");
981 assert_eq!(encoded2, "aGk=");
982
983 let encoded3 = base64_encode(b"test");
984 assert_eq!(encoded3, "dGVzdA==");
985 }
986
987 #[test]
988 fn test_scheme_detection() {
989 assert_eq!(Url::new("https://example.com").scheme(), Some("https"));
990 assert_eq!(Url::new("http://example.com").scheme(), Some("http"));
991 assert_eq!(Url::new("ftp://example.com").scheme(), Some("ftp"));
992 assert_eq!(Url::new("ws://example.com").scheme(), Some("ws"));
993 assert_eq!(Url::new("data:text/plain,hello").scheme(), Some("data"));
994 assert_eq!(
995 Url::new("blob:https://example.com/uuid").scheme(),
996 Some("blob")
997 );
998 assert_eq!(Url::new("/local/path").scheme(), Some("file"));
999 }
1000
1001 #[test]
1002 fn test_path_parsing() {
1003 let url1 = Url::new("https://example.com/api/v1/users?id=123#section");
1004 assert_eq!(url1.path(), "/api/v1/users");
1005
1006 let url2 = Url::new("https://example.com");
1007 assert_eq!(url2.path(), "/");
1008
1009 let url3 = Url::new("/local/path/file.txt");
1010 assert_eq!(url3.path(), "/local/path/file.txt");
1011 }
1012
1013 #[test]
1014 fn test_absolute_relative_detection() {
1015 assert!(Url::new("https://example.com").is_absolute());
1016 assert!(Url::new("/absolute/path").is_absolute());
1017 assert!(Url::new("C:\\Windows\\file.txt").is_absolute());
1018 assert!(Url::new("data:text/plain,hello").is_absolute());
1019
1020 assert!(Url::new("relative/path").is_relative());
1021 assert!(Url::new("./relative/path").is_relative());
1022 assert!(Url::new("../parent/path").is_relative());
1023 assert!(Url::new("file.txt").is_relative());
1024 }
1025}