diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad7ed4d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Update packages index + run: sudo apt update + - name: Install system packages + run: sudo apt install -y libglib2.0-dev + - name: Run clippy + run: cargo clippy --all-targets + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index 479de04..4d36cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ggemtext" -version = "0.1.3" -edition = "2021" +version = "0.7.0" +edition = "2024" license = "MIT" readme = "README.md" description = "Glib-oriented Gemtext API" @@ -15,6 +15,7 @@ categories = [ ] repository = "https://github.com/YGGverse/ggemtext" -[dependencies.gtk] -package = "gtk4" -version = "0.9.1" +[dependencies.glib] +package = "glib" +version = "0.21.0" +features = ["v2_66"] diff --git a/README.md b/README.md index 5ac1f1e..59d9f1d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # ggemtext +![Build](https://github.com/YGGverse/ggemtext/actions/workflows/build.yml/badge.svg) +[![Documentation](https://docs.rs/ggemtext/badge.svg)](https://docs.rs/ggemtext) +[![crates.io](https://img.shields.io/crates/v/ggemtext.svg)](https://crates.io/crates/ggemtext) + Glib-oriented [Gemtext](https://geminiprotocol.net/docs/gemtext.gmi) API ## Install @@ -10,24 +14,12 @@ cargo add ggemtext ## Usage +* [Documentation](https://docs.rs/ggemtext/latest/) + ### Line Line parser, useful for [TextTag](https://docs.gtk.org/gtk4/class.TextTag.html) operations in [TextBuffer](https://docs.gtk.org/gtk4/class.TextBuffer.html) context. -**Connect dependencies** - -``` rust -use ggemtext::line::{ - code::Code, - header::{Header, Level}, - link::Link, - list::List, - quote::Quote, -}; -``` - -**Prepare document** - Iterate Gemtext lines to continue with [Line](#Line) API: ``` rust @@ -38,87 +30,114 @@ for line in gemtext.lines() { #### Code -##### Inline - ``` rust -match Code::inline_from("```inline```") { - Some(inline) => assert_eq!(inline.value, "inline"), - None => assert!(false), -}; -``` +use ggemtext::line::Code; +match Code::begin_from("```alt") { + Some(mut code) => { + assert!(code.continue_from("line 1").is_ok()); + assert!(code.continue_from("line 2").is_ok()); + assert!(code.continue_from("```").is_ok()); // complete -##### Multiline - -``` rust -match Code::multiline_begin_from("```alt") { - Some(mut multiline) => { - Code::multiline_continue_from(&mut multiline, "line 1"); - Code::multiline_continue_from(&mut multiline, "line 2"); - Code::multiline_continue_from(&mut multiline, "```"); // complete - - assert!(multiline.completed); - assert_eq!(multiline.alt, Some("alt".into())); - assert_eq!(multiline.buffer.len(), 3); + assert!(code.is_completed); + assert_eq!(code.alt, Some("alt".into())); + assert_eq!(code.value.len(), 12 + 2); // +NL } - None => assert!(false), -}; + None => unreachable!(), +} ``` #### Header +**Struct** + ``` rust -match Header::from("# H1") { +use ggemtext::line::{Header, header::Level}; +match Header::parse("# H1") { Some(h1) => { - assert_eq!(h1.level as i8, Level::H1 as i8); + assert_eq!(h1.level as u8, Level::H1 as u8); assert_eq!(h1.value, "H1"); } - None => assert!(false), -}; // H1, H2, H3 + None => unreachable!(), +} // H1, H2, H3 +``` + +**Trait** + +``` rust +use ggemtext::line::header::{Gemtext, Level}; +assert_eq!("# H1".as_value(), Some("H1")); +assert_eq!("H1".to_source(&Level::H1), "# H1"); +// H1, H2, H3 ``` #### Link ``` rust -match Link::from( - "=> gemini://geminiprotocol.net 1965-01-19 Gemini", - None, // absolute path given, base not wanted - Some(>k::glib::TimeZone::local()), -) { - Some(link) => { - // Alt - assert_eq!(link.alt, Some("Gemini".into())); +use ggemtext::line::Link; - // Date - match link.timestamp { - Some(timestamp) => { - assert_eq!(timestamp.year(), 1965); - assert_eq!(timestamp.month(), 01); - assert_eq!(timestamp.day_of_month(), 19); - } - None => assert!(false), - } +const SOURCE: &str = "=> gemini://geminiprotocol.net 1965-01-19 Gemini"; - // URI - assert_eq!(link.uri.to_string(), "gemini://geminiprotocol.net"); - } - None => assert!(false), -}; +let link = Link::parse(SOURCE).unwrap(); + +assert_eq!(link.alt, Some("1965-01-19 Gemini".to_string())); +assert_eq!(link.url, "gemini://geminiprotocol.net"); + +let uri = link.uri(None).unwrap(); +assert_eq!(uri.scheme(), "gemini"); +assert_eq!(uri.host().unwrap(), "geminiprotocol.net"); + +let time = link.time(Some(&glib::TimeZone::local())).unwrap(); +assert_eq!(time.year(), 1965); +assert_eq!(time.month(), 1); +assert_eq!(time.day_of_month(), 19); + +assert_eq!(link.to_source(), SOURCE); ``` #### List +**Struct** + ``` rust -match List::from("* Item") { +use ggemtext::line::List; +match List::parse("* Item") { Some(list) => assert_eq!(list.value, "Item"), - None => assert!(false), -}; + None => unreachable!(), +} +``` + +**Trait** + +``` rust +use ggemtext::line::list::Gemtext; +assert_eq!("* Item".as_value(), Some("Item")) +assert_eq!("Item".to_source(), "* Item") ``` #### Quote +**Struct** + ``` rust -match Quote::from("> Quote") { +use ggemtext::line::Quote; +match Quote::parse("> Quote") { Some(quote) => assert_eq!(quote.value, "Quote"), - None => assert!(false), -}; -``` \ No newline at end of file + None => unreachable!(), +} +``` + +**Trait** + +``` rust +use ggemtext::line::quote::Gemtext; +assert_eq!("> Quote".as_value(), Some("Quote")) +assert_eq!("Quote".to_source(), "> Quote") +``` + +## Integrations + +* [Yoda](https://github.com/YGGverse/Yoda) - Browser for Gemini Protocol + +## See also + +* [ggemini](https://github.com/YGGverse/ggemini) - Glib-oriented client for Gemini Protocol \ No newline at end of file diff --git a/src/line.rs b/src/line.rs index fd70850..2cf472a 100644 --- a/src/line.rs +++ b/src/line.rs @@ -3,3 +3,9 @@ pub mod header; pub mod link; pub mod list; pub mod quote; + +pub use code::Code; +pub use header::Header; +pub use link::Link; +pub use list::List; +pub use quote::Quote; diff --git a/src/line/code.rs b/src/line/code.rs index f9ab3ee..165a308 100644 --- a/src/line/code.rs +++ b/src/line/code.rs @@ -1,25 +1,90 @@ -pub mod inline; -pub mod multiline; +pub mod error; +pub use error::Error; -use inline::Inline; -use multiline::Multiline; +pub const TAG: &str = "```"; +pub const NEW_LINE: char = '\n'; +/// Multi-line [preformatted](https://geminiprotocol.net/docs/gemtext-specification.gmi#in-pre-formatted-mode) entity holder pub struct Code { - // nothing yet.. + pub alt: Option, + pub value: String, + pub is_completed: bool, } impl Code { - // Inline - pub fn inline_from(line: &str) -> Option { - Inline::from(line) + // Constructors + + /// Search in line string for tag open, + /// return Self constructed on success or None + pub fn begin_from(line: &str) -> Option { + if line.starts_with(TAG) { + let alt = line.trim_start_matches(TAG).trim(); + + return Some(Self { + alt: match alt.is_empty() { + true => None, + false => Some(alt.to_string()), + }, + value: String::new(), + is_completed: false, + }); + } + None } - // Multiline - pub fn multiline_begin_from(line: &str) -> Option { - Multiline::begin_from(line) + /// Continue preformatted buffer from line string, + /// set `completed` as True on close tag found + pub fn continue_from(&mut self, line: &str) -> Result<(), Error> { + // Make sure buffer not completed yet + if self.is_completed { + return Err(Error::Completed); + } + + // Append to value, trim close tag on exists + self.value.push_str(line.trim_end_matches(TAG)); + + // Line contain close tag + if line.ends_with(TAG) { + self.is_completed = true; + } else { + self.value.push(NEW_LINE); + } + + Ok(()) } - pub fn multiline_continue_from(this: &mut Multiline, line: &str) { - Multiline::continue_from(this, line) + // Converters + + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) format + pub fn to_source(&self) -> String { + format!( + "{TAG}{}{NEW_LINE}{}{TAG}", + match &self.alt { + Some(alt) => format!(" {}", alt.trim()), + None => String::new(), + }, + self.value + ) + } +} + +#[test] +fn test() { + match Code::begin_from("```alt") { + Some(mut code) => { + assert!(code.continue_from("line 1").is_ok()); + assert!(code.continue_from("line 2").is_ok()); + assert!(code.continue_from("```").is_ok()); // complete + + assert!(code.is_completed); + assert_eq!(code.alt, Some("alt".into())); + assert_eq!(code.value.len(), 12 + 2); // +NL + + assert_eq!( + code.to_source(), + format!("{TAG} alt{NEW_LINE}line 1{NEW_LINE}line 2{NEW_LINE}{TAG}") + ) + } + None => unreachable!(), } } diff --git a/src/line/code/error.rs b/src/line/code/error.rs new file mode 100644 index 0000000..78d0474 --- /dev/null +++ b/src/line/code/error.rs @@ -0,0 +1,16 @@ +use std::fmt::{Display, Formatter, Result}; + +#[derive(Debug)] +pub enum Error { + Completed, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> Result { + match self { + Self::Completed => { + write!(f, "Could not continue as completed!") + } + } + } +} diff --git a/src/line/code/inline.rs b/src/line/code/inline.rs deleted file mode 100644 index 7baddb8..0000000 --- a/src/line/code/inline.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; - -pub struct Inline { - pub value: GString, -} - -impl Inline { - pub fn from(line: &str) -> Option { - // Parse line - let regex = Regex::split_simple( - r"^`{3}([^`]*)`{3}$", - line, - RegexCompileFlags::DEFAULT, - RegexMatchFlags::DEFAULT, - ); - - // Detect value - let value = regex.get(1)?; - - if value.trim().is_empty() { - return None; - } - - // Result - Some(Self { - value: GString::from(value.as_str()), - }) - } -} diff --git a/src/line/code/multiline.rs b/src/line/code/multiline.rs deleted file mode 100644 index cef1518..0000000 --- a/src/line/code/multiline.rs +++ /dev/null @@ -1,46 +0,0 @@ -use gtk::glib::GString; - -pub struct Multiline { - pub alt: Option, - pub buffer: Vec, - pub completed: bool, -} - -impl Multiline { - /// Search in line for tag open, - /// return Self constructed on success or None - pub fn begin_from(line: &str) -> Option { - if line.starts_with("```") { - let alt = line.trim_start_matches("```").trim(); - - return Some(Self { - alt: match alt.is_empty() { - true => None, - false => Some(GString::from(alt)), - }, - buffer: Vec::new(), - completed: false, - }); - } - - None - } - - /// Continue preformatted buffer from line, - /// set `completed` as True on close tag found - pub fn continue_from(&mut self, line: &str) { - // Make sure buffer not completed yet - if self.completed { - panic!("Could not continue as completed") // @TODO handle - } - - // Line contain close tag - if line.ends_with("```") { - self.completed = true; - } - - // Append data to the buffer, trim close tag on exists - self.buffer - .push(GString::from(line.trim_end_matches("```"))); - } -} diff --git a/src/line/header.rs b/src/line/header.rs index 24dc248..1455963 100644 --- a/src/line/header.rs +++ b/src/line/header.rs @@ -1,47 +1,76 @@ -use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; +pub mod gemtext; +pub mod level; -pub enum Level { - H1, - H2, - H3, -} +pub use gemtext::Gemtext; +pub use level::Level; +/// [Header](https://geminiprotocol.net/docs/gemtext-specification.gmi#heading-lines) entity holder pub struct Header { - pub value: GString, pub level: Level, + pub value: String, } impl Header { - pub fn from(line: &str) -> Option { - // Parse line - let regex = Regex::split_simple( - r"^(#{1,3})\s*(.+)$", - line, - RegexCompileFlags::DEFAULT, - RegexMatchFlags::DEFAULT, - ); + // Constructors - // Detect header level - let level = regex.get(1)?; - - let level = match level.len() { - 1 => Level::H1, - 2 => Level::H2, - 3 => Level::H3, - _ => return None, - }; - - // Detect header value - let value = regex.get(2)?; - - if value.trim().is_empty() { - return None; + /// Parse `Self` from line string + pub fn parse(line: &str) -> Option { + if let Some(value) = line.as_h1_value() { + return Some(Self { + level: Level::H1, + value: value.to_string(), + }); } + if let Some(value) = line.as_h2_value() { + return Some(Self { + level: Level::H2, + value: value.to_string(), + }); + } + if let Some(value) = line.as_h3_value() { + return Some(Self { + level: Level::H3, + value: value.to_string(), + }); + } + None + } - // Result - Some(Self { - level, - value: GString::from(value.as_str()), - }) + // Converters + + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn to_source(&self) -> String { + self.value.to_source(&self.level) } } + +#[test] +fn test() { + fn test(source: &str, value: &str, level: Level) { + fn f(s: &str) -> String { + s.chars().filter(|&c| c != ' ').collect() + } + let header = Header::parse(source).unwrap(); + assert_eq!(header.value, value); + assert_eq!(header.level.as_tag(), level.as_tag()); + assert_eq!(f(&header.to_source()), f(source)); + } + // h1 + test("# H1", "H1", Level::H1); + test("# H1 ", "H1", Level::H1); + test("#H1", "H1", Level::H1); + test("#H1 ", "H1", Level::H1); + // h2 + test("## H2", "H2", Level::H2); + test("## H2 ", "H2", Level::H2); + test("##H2", "H2", Level::H2); + test("##H2 ", "H2", Level::H2); + // h3 + test("### H3", "H3", Level::H3); + test("### H3 ", "H3", Level::H3); + test("###H3", "H3", Level::H3); + test("###H3 ", "H3", Level::H3); + // other + assert!(Header::parse("H").is_none()); + assert!(Header::parse("#### H").is_none()) +} diff --git a/src/line/header/gemtext.rs b/src/line/header/gemtext.rs new file mode 100644 index 0000000..283edbb --- /dev/null +++ b/src/line/header/gemtext.rs @@ -0,0 +1,98 @@ +use super::Level; + +pub trait Gemtext { + /// Get [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) value for `Self` + fn as_value(&self) -> Option<&str>; + /// Get parsed H1 header value for `Self` + fn as_h1_value(&self) -> Option<&str>; + /// Get parsed H2 header value `Self` + fn as_h2_value(&self) -> Option<&str>; + /// Get parsed H3 header value `Self` + fn as_h3_value(&self) -> Option<&str>; + /// Get parsed header value `Self` match `Level` + fn as_value_match_level(&self, level: Level) -> Option<&str>; + /// Convert `Self` to `Level` + fn to_level(&self) -> Option; + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + fn to_source(&self, level: &Level) -> String; +} + +impl Gemtext for str { + fn as_value(&self) -> Option<&str> { + if let Some(value) = self.as_h1_value() { + return Some(value); + } + if let Some(value) = self.as_h2_value() { + return Some(value); + } + if let Some(value) = self.as_h3_value() { + return Some(value); + } + None + } + fn as_h1_value(&self) -> Option<&str> { + self.as_value_match_level(Level::H1) + } + fn as_h2_value(&self) -> Option<&str> { + self.as_value_match_level(Level::H2) + } + fn as_h3_value(&self) -> Option<&str> { + self.as_value_match_level(Level::H3) + } + fn as_value_match_level(&self, level: Level) -> Option<&str> { + self.strip_prefix(level.as_tag()) + .map(|postfix| postfix.trim()) + .filter(|value| !value.starts_with(Level::H1.as_tag())) + } + fn to_level(&self) -> Option { + if self.as_h1_value().is_some() { + return Some(Level::H1); + } + if self.as_h2_value().is_some() { + return Some(Level::H2); + } + if self.as_h3_value().is_some() { + return Some(Level::H3); + } + None + } + fn to_source(&self, level: &Level) -> String { + format!("{} {}", level.as_tag(), self.trim()) + } +} + +#[test] +fn test() { + const VALUE: &str = "H"; + let mut value: Option<&str> = Some(VALUE); + for t in ["#", "##", "###", "####"] { + if t.len() > 3 { + value = None; + } + assert_eq!(format!("{t}{VALUE}").as_value(), value); + assert_eq!(format!("{t}{VALUE} ").as_value(), value); + assert_eq!(format!("{t} {VALUE}").as_value(), value); + assert_eq!(format!("{t} {VALUE} ").as_value(), value); + } + + fn to_source(l: &Level) { + assert_eq!(VALUE.to_source(l), format!("{} {VALUE}", l.as_tag())); + } + to_source(&Level::H1); + to_source(&Level::H2); + to_source(&Level::H3); + + fn to_level(l: &Level) { + fn assert(s: String, l: &str) { + assert_eq!(s.to_level().unwrap().as_tag(), l); + } + let t = l.as_tag(); + assert(format!("{t} {VALUE}"), t); + assert(format!("{t} {VALUE} "), t); + assert(format!("{t}{VALUE} "), t); + assert(format!("{t} {VALUE} "), t); + } + to_level(&Level::H1); + to_level(&Level::H2); + to_level(&Level::H3); +} diff --git a/src/line/header/level.rs b/src/line/header/level.rs new file mode 100644 index 0000000..2fa001b --- /dev/null +++ b/src/line/header/level.rs @@ -0,0 +1,16 @@ +/// [Header](https://geminiprotocol.net/docs/gemtext-specification.gmi#heading-lines) type holder +pub enum Level { + H1, + H2, + H3, +} + +impl Level { + pub fn as_tag(&self) -> &str { + match self { + Level::H1 => "#", + Level::H2 => "##", + Level::H3 => "###", + } + } +} diff --git a/src/line/link.rs b/src/line/link.rs index 09543b3..0d2aff9 100644 --- a/src/line/link.rs +++ b/src/line/link.rs @@ -1,91 +1,119 @@ -use gtk::glib::{ - DateTime, GString, Regex, RegexCompileFlags, RegexMatchFlags, TimeZone, Uri, UriFlags, -}; +use glib::{DateTime, TimeZone, Uri, UriFlags}; +const S: char = ' '; +pub const TAG: &str = "=>"; + +/// [Link](https://geminiprotocol.net/docs/gemtext-specification.gmi#link-lines) entity holder pub struct Link { - pub alt: Option, // [optional] alternative link description - pub is_external: Option, // [optional] external link indication, on base option provided - pub timestamp: Option, // [optional] valid link DateTime object - pub uri: Uri, // [required] valid link URI object + /// For performance reasons, hold Gemtext date and alternative together as the optional String + /// * to extract valid [DateTime](https://docs.gtk.org/glib/struct.DateTime.html) use `time` implementation method + pub alt: Option, + /// For performance reasons, hold URL as the raw String + /// * to extract valid [Uri](https://docs.gtk.org/glib/struct.Uri.html) use `uri` implementation method + pub url: String, } impl Link { - pub fn from(line: &str, base: Option<&Uri>, timezone: Option<&TimeZone>) -> Option { - // Define initial values - let mut alt = None; - let mut timestamp = None; - let mut is_external = None; + // Constructors - // Begin line parse - let regex = Regex::split_simple( - r"^=>\s*([^\s]+)\s*(\d{4}-\d{2}-\d{2})?\s*(.+)?$", - line, - RegexCompileFlags::DEFAULT, - RegexMatchFlags::DEFAULT, - ); - - // Detect address required to continue - let unresolved_address = regex.get(1)?; - - // Convert address to the valid URI - let uri = match base { - // Base conversion requested - Some(base_uri) => { - // Convert relative address to absolute - match Uri::resolve_relative( - Some(&base_uri.to_str()), - unresolved_address.as_str(), - UriFlags::NONE, - ) { - Ok(resolved_str) => { - // Try convert string to the valid URI - match Uri::parse(&resolved_str, UriFlags::NONE) { - Ok(resolved_uri) => { - // Change external status - is_external = Some(resolved_uri.scheme() != base_uri.scheme()); - - // Result - resolved_uri - } - Err(_) => return None, - } - } - Err(_) => return None, - } - } - // Base resolve not requested - None => { - // Just try convert address to valid URI - match Uri::parse(&unresolved_address, UriFlags::NONE) { - Ok(unresolved_uri) => unresolved_uri, - Err(_) => return None, - } - } - }; - - // Timestamp - if let Some(date) = regex.get(2) { - // @TODO even possible, but simpler to work with `DateTime` API - // await for new features in `Date` as better in Gemini context - // https://docs.gtk.org/glib/struct.Date.html - timestamp = match DateTime::from_iso8601(&format!("{date}T00:00:00"), timezone) { - Ok(value) => Some(value), - Err(_) => None, - } + /// Parse `Self` from line string + pub fn parse(line: &str) -> Option { + let l = line.strip_prefix(TAG)?.trim(); + let u = l.find(S).map_or(l, |i| &l[..i]); + if u.is_empty() { + return None; } - - // Alt - if let Some(value) = regex.get(3) { - if !value.is_empty() { - alt = Some(GString::from(value.as_str())) - } - }; - Some(Self { - alt, - is_external, - timestamp, - uri, + alt: l + .get(u.len()..) + .map(|a| a.trim()) + .filter(|a| !a.is_empty()) + .map(|a| a.to_string()), + url: u.to_string(), }) } + + // Converters + + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn to_source(&self) -> String { + let mut s = String::with_capacity( + TAG.len() + self.url.len() + self.alt.as_ref().map_or(0, |a| a.len()) + 2, + ); + s.push_str(TAG); + s.push(S); + s.push_str(self.url.trim()); + if let Some(ref alt) = self.alt { + s.push(S); + s.push_str(alt.trim()); + } + s + } + + // Getters + + /// Get valid [DateTime](https://docs.gtk.org/glib/struct.DateTime.html) for `Self` + pub fn time(&self, timezone: Option<&TimeZone>) -> Option { + let a = self.alt.as_ref()?; + let t = &a[..a.find(S).unwrap_or(a.len())]; + DateTime::from_iso8601(&format!("{t}T00:00:00"), timezone).ok() + } + + /// Get valid [Uri](https://docs.gtk.org/glib/struct.Uri.html) for `Self` + pub fn uri(&self, base: Option<&Uri>) -> Option { + // Relative scheme patch + // https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 + let unresolved_address = match self.url.strip_prefix("//") { + Some(p) => { + let b = base?; + let s = p.trim_start_matches(":"); + &format!( + "{}://{}", + b.scheme(), + if s.is_empty() { + format!("{}/", b.host()?) + } else { + s.into() + } + ) + } + None => &self.url, + }; + // Convert address to the valid URI, + // resolve to absolute URL format if the target is relative + match base { + Some(base_uri) => match Uri::resolve_relative( + Some(&base_uri.to_str()), + unresolved_address, + UriFlags::NONE, + ) { + Ok(resolved_str) => Uri::parse(&resolved_str, UriFlags::NONE).ok(), + Err(_) => None, + }, + None => Uri::parse(unresolved_address, UriFlags::NONE).ok(), + } + } +} + +#[test] +fn test() { + use crate::line::Link; + + const SOURCE: &str = "=> gemini://geminiprotocol.net 1965-01-19 Gemini"; + + let link = Link::parse(SOURCE).unwrap(); + + assert_eq!(link.alt, Some("1965-01-19 Gemini".to_string())); + assert_eq!(link.url, "gemini://geminiprotocol.net"); + + let uri = link.uri(None).unwrap(); + assert_eq!(uri.scheme(), "gemini"); + assert_eq!(uri.host().unwrap(), "geminiprotocol.net"); + + let time = link.time(Some(&glib::TimeZone::local())).unwrap(); + assert_eq!(time.year(), 1965); + assert_eq!(time.month(), 1); + assert_eq!(time.day_of_month(), 19); + + assert_eq!(link.to_source(), SOURCE); } diff --git a/src/line/list.rs b/src/line/list.rs index f2598e5..44d69f4 100644 --- a/src/line/list.rs +++ b/src/line/list.rs @@ -1,29 +1,38 @@ -use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; +pub mod gemtext; +pub use gemtext::Gemtext; +/// [List item](https://geminiprotocol.net/docs/gemtext-specification.gmi#list-items) tag +pub const TAG: char = '*'; + +/// [List](https://geminiprotocol.net/docs/gemtext-specification.gmi#list-items) entity holder pub struct List { - pub value: GString, + pub value: String, } impl List { - pub fn from(line: &str) -> Option { - // Parse line - let regex = Regex::split_simple( - r"^\*\s*(.+)$", - line, - RegexCompileFlags::DEFAULT, - RegexMatchFlags::DEFAULT, - ); + // Constructors - // Detect value - let value = regex.get(1)?; - - if value.trim().is_empty() { - return None; - } - - // Result + /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn parse(line: &str) -> Option { Some(Self { - value: GString::from(value.as_str()), + value: line.as_value()?.to_string(), }) } + + // Converters + + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn to_source(&self) -> String { + self.value.to_source() + } +} + +#[test] +fn test() { + const SOURCE: &str = "* Item"; + const VALUE: &str = "Item"; + + let list = List::parse(SOURCE).unwrap(); + assert_eq!(list.value, VALUE); + assert_eq!(list.to_source(), SOURCE); } diff --git a/src/line/list/gemtext.rs b/src/line/list/gemtext.rs new file mode 100644 index 0000000..5700287 --- /dev/null +++ b/src/line/list/gemtext.rs @@ -0,0 +1,26 @@ +use super::TAG; + +pub trait Gemtext { + /// Get [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) value for `Self` + fn as_value(&self) -> Option<&str>; + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + fn to_source(&self) -> String; +} + +impl Gemtext for str { + fn as_value(&self) -> Option<&str> { + self.strip_prefix(TAG).map(|s| s.trim()) + } + fn to_source(&self) -> String { + format!("{TAG} {}", self.trim()) + } +} + +#[test] +fn test() { + const SOURCE: &str = "* Item"; + const VALUE: &str = "Item"; + + assert_eq!(SOURCE.as_value(), Some(VALUE)); + assert_eq!(VALUE.to_source(), SOURCE) +} diff --git a/src/line/quote.rs b/src/line/quote.rs index 20d69b6..e098703 100644 --- a/src/line/quote.rs +++ b/src/line/quote.rs @@ -1,29 +1,39 @@ -use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; +pub mod gemtext; +pub use gemtext::Gemtext; +/// [Quote item](https://geminiprotocol.net/docs/gemtext-specification.gmi#quote-lines) tag +pub const TAG: char = '>'; + +/// [Quote](https://geminiprotocol.net/docs/gemtext-specification.gmi#quote-lines) entity holder pub struct Quote { - pub value: GString, + pub value: String, } impl Quote { - pub fn from(line: &str) -> Option { - // Parse line - let regex = Regex::split_simple( - r"^>\s*(.+)$", - line, - RegexCompileFlags::DEFAULT, - RegexMatchFlags::DEFAULT, - ); + // Constructors - // Detect value - let value = regex.get(1)?; - - if value.trim().is_empty() { - return None; - } - - // Result + /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn parse(line: &str) -> Option { Some(Self { - value: GString::from(value.as_str()), + value: line.as_value()?.to_string(), }) } + + // Converters + + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + pub fn to_source(&self) -> String { + self.value.to_source() + } +} + +#[test] +fn test() { + const SOURCE: &str = "> Quote"; + const VALUE: &str = "Quote"; + + let quote = Quote::parse(SOURCE).unwrap(); + + assert_eq!(quote.value, VALUE); + assert_eq!(quote.to_source(), SOURCE); } diff --git a/src/line/quote/gemtext.rs b/src/line/quote/gemtext.rs new file mode 100644 index 0000000..e55d2f3 --- /dev/null +++ b/src/line/quote/gemtext.rs @@ -0,0 +1,26 @@ +use super::TAG; + +pub trait Gemtext { + /// Get [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) value for `Self` + fn as_value(&self) -> Option<&str>; + /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line + fn to_source(&self) -> String; +} + +impl Gemtext for str { + fn as_value(&self) -> Option<&str> { + self.strip_prefix(TAG).map(|s| s.trim()) + } + fn to_source(&self) -> String { + format!("{TAG} {}", self.trim()) + } +} + +#[test] +fn test() { + const SOURCE: &str = "> Quote"; + const VALUE: &str = "Quote"; + + assert_eq!(SOURCE.as_value(), Some(VALUE)); + assert_eq!(VALUE.to_source(), SOURCE) +} diff --git a/tests/integration.gmi b/tests/integration.gmi index b7a7843..400695a 100644 --- a/tests/integration.gmi +++ b/tests/integration.gmi @@ -7,12 +7,14 @@ => gemini://geminiprotocol.net Gemini => gemini://geminiprotocol.net 1965-01-19 Gemini => /docs/gemtext.gmi 1965-01-19 Gemini +=> //:geminiprotocol.net +=> //geminiprotocol.net +=> //geminiprotocol.net/path +=> // * Listing item 1 * Listing item 2 -```inline code``` - ``` alt text multi preformatted line diff --git a/tests/integration.rs b/tests/integration.rs index 475398a..d96ccff 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,12 +1,9 @@ use ggemtext::line::{ - code::{inline::Inline, multiline::Multiline, Code}, + Code, Link, List, Quote, header::{Header, Level}, - link::Link, - list::List, - quote::Quote, }; -use gtk::glib::{TimeZone, Uri, UriFlags}; +use glib::{TimeZone, Uri, UriFlags}; use std::fs; #[test] @@ -14,204 +11,238 @@ fn gemtext() { match fs::read_to_string("tests/integration.gmi") { Ok(gemtext) => { // Init tags collection - let mut code_inline: Vec = Vec::new(); - let mut code_multiline: Vec = Vec::new(); - let mut header: Vec
= Vec::new(); - let mut link: Vec = Vec::new(); + let mut code: Vec = Vec::new(); + let mut headers: Vec
= Vec::new(); + let mut links: Vec = Vec::new(); let mut list: Vec = Vec::new(); let mut quote: Vec = Vec::new(); // Define preformatted buffer - let mut code_multiline_buffer: Option = None; + let mut code_buffer: Option = None; // Define base URI as integration.gmi contain one relative link - let base = match Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE) { - Ok(uri) => Some(uri), - Err(_) => None, - }; + let base = Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE).unwrap(); // Define timezone as integration.gmi contain one links with date - let timezone = Some(TimeZone::local()); + let timezone = TimeZone::local(); // Parse document by line for line in gemtext.lines() { - // Inline code - if let Some(result) = Code::inline_from(line) { - code_inline.push(result); - continue; - } - - // Multiline code - match code_multiline_buffer { + match code_buffer { None => { - if let Some(code) = Code::multiline_begin_from(line) { - code_multiline_buffer = Some(code); + if let Some(code) = Code::begin_from(line) { + code_buffer = Some(code); continue; } } - Some(ref mut result) => { - Code::multiline_continue_from(result, line); - if result.completed { - code_multiline.push(code_multiline_buffer.take().unwrap()); - code_multiline_buffer = None; + Some(ref mut c) => { + assert!(c.continue_from(line).is_ok()); + if c.is_completed { + code.push(code_buffer.take().unwrap()); + code_buffer = None; } continue; } }; // Header - if let Some(result) = Header::from(line) { - header.push(result); + if let Some(result) = Header::parse(line) { + headers.push(result); continue; } // Link - if let Some(result) = Link::from(line, base.as_ref(), timezone.as_ref()) { - link.push(result); + if let Some(result) = Link::parse(line) { + links.push(result); continue; } // List - if let Some(result) = List::from(line) { + if let Some(result) = List::parse(line) { list.push(result); continue; } // Quote - if let Some(result) = Quote::from(line) { + if let Some(result) = Quote::parse(line) { quote.push(result); continue; } } - // Validate inline code - assert_eq!(code_inline.len(), 1); - assert_eq!(code_inline.get(0).unwrap().value, "inline code"); - - // Validate multiline code - assert_eq!(code_multiline.len(), 2); - + // Validate code + assert_eq!(code.len(), 2); { - let item = code_multiline.get(0).unwrap(); + let item = code.first().unwrap(); assert_eq!(item.alt.clone().unwrap(), "alt text"); - assert_eq!(item.buffer.len(), 3); - assert_eq!(item.buffer.get(0).unwrap(), "multi"); - assert_eq!(item.buffer.get(1).unwrap(), " preformatted line"); + + assert_eq!(item.value.lines().count(), 2); + + let mut lines = item.value.lines(); + assert_eq!(lines.next().unwrap(), "multi"); + assert_eq!(lines.next().unwrap(), " preformatted line"); } // #1 { - let item = code_multiline.get(1).unwrap(); + let item = code.get(1).unwrap(); assert_eq!(item.alt.clone(), None); - assert_eq!(item.buffer.len(), 3); - assert_eq!(item.buffer.get(0).unwrap(), "alt-less"); - assert_eq!(item.buffer.get(1).unwrap(), " preformatted line"); + + assert_eq!(item.value.lines().count(), 2); + + let mut lines = item.value.lines(); + assert_eq!(lines.next().unwrap(), "alt-less"); + assert_eq!(lines.next().unwrap(), " preformatted line"); } // #2 // Validate headers - assert_eq!(header.len(), 3); + assert_eq!(headers.len(), 3); - fn to_i8(level: &Level) -> i8 { + fn to_u8(level: &Level) -> u8 { match level { Level::H1 => 1, Level::H2 => 2, Level::H3 => 3, } } // comparison helper - + let mut header = headers.iter(); { - let item = header.get(0).unwrap(); + let item = header.next().unwrap(); - assert_eq!(to_i8(&item.level), to_i8(&Level::H1)); + assert_eq!(to_u8(&item.level), to_u8(&Level::H1)); assert_eq!(item.value, "H1"); } // #1 - { - let item = header.get(1).unwrap(); + let item = header.next().unwrap(); - assert_eq!(to_i8(&item.level), to_i8(&Level::H2)); + assert_eq!(to_u8(&item.level), to_u8(&Level::H2)); assert_eq!(item.value, "H2"); } // #2 - { - let item = header.get(2).unwrap(); + let item = header.next().unwrap(); - assert_eq!(to_i8(&item.level), to_i8(&Level::H3)); + assert_eq!(to_u8(&item.level), to_u8(&Level::H3)); assert_eq!(item.value, "H3"); } // #3 // Validate links - assert_eq!(link.len(), 5); - + assert_eq!(links.len(), 9); + let mut link = links.iter(); { - let item = link.get(0).unwrap(); + let item = link.next().unwrap(); assert_eq!(item.alt, None); - assert_eq!(item.timestamp, None); - assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_str(), + "gemini://geminiprotocol.net" + ); } // #1 - { - let item = link.get(1).unwrap(); - - assert_eq!(item.alt, None); - - let timestamp = item.timestamp.clone().unwrap(); - assert_eq!(timestamp.year(), 1965); - assert_eq!(timestamp.month(), 01); - assert_eq!(timestamp.day_of_month(), 19); - - assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); - } // #2 - - { - let item = link.get(2).unwrap(); - - assert_eq!(item.alt.clone().unwrap(), "Gemini"); - assert_eq!(item.timestamp, None); - assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); - } // #3 - - { - let item = link.get(3).unwrap(); - - assert_eq!(item.alt.clone().unwrap(), "Gemini"); - - let timestamp = item.timestamp.clone().unwrap(); - assert_eq!(timestamp.year(), 1965); - assert_eq!(timestamp.month(), 01); - assert_eq!(timestamp.day_of_month(), 19); - - assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); - } // #4 - - { - let item = link.get(4).unwrap(); - - assert_eq!(item.alt.clone().unwrap(), "Gemini"); - - let timestamp = item.timestamp.clone().unwrap(); - assert_eq!(timestamp.year(), 1965); - assert_eq!(timestamp.month(), 01); - assert_eq!(timestamp.day_of_month(), 19); + let item = link.next().unwrap(); assert_eq!( - item.uri.to_str(), + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net" + ); + + let time = item.time(Some(&timezone)).unwrap(); + assert_eq!(time.year(), 1965); + assert_eq!(time.month(), 1); + assert_eq!(time.day_of_month(), 19); + + assert_eq!(item.alt, Some("1965-01-19".to_string())); + } // #2 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt.clone().unwrap(), "Gemini"); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net" + ); + } // #3 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string())); + + let time = item.time(Some(&timezone)).unwrap(); + assert_eq!(time.year(), 1965); + assert_eq!(time.month(), 1); + assert_eq!(time.day_of_month(), 19); + + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net" + ); + } // #4 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string())); + + let time = item.time(Some(&timezone)).unwrap(); + assert_eq!(time.year(), 1965); + assert_eq!(time.month(), 1); + assert_eq!(time.day_of_month(), 19); + + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), "gemini://geminiprotocol.net/docs/gemtext.gmi" ); } // #5 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, None); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net" + ); + } // #6 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, None); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net" + ); + } // #7 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, None); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net/path" + ); + } // #8 + { + let item = link.next().unwrap(); + + assert_eq!(item.alt, None); + assert_eq!(item.time(Some(&timezone)), None); + assert_eq!( + item.uri(Some(&base)).unwrap().to_string(), + "gemini://geminiprotocol.net/" + ); + } // #9 // Validate lists assert_eq!(list.len(), 2); - assert_eq!(list.get(0).unwrap().value, "Listing item 1"); - assert_eq!(list.get(1).unwrap().value, "Listing item 2"); + assert_eq!(list.first().unwrap().value, "Listing item 1"); + assert_eq!(list.last().unwrap().value, "Listing item 2"); // Validate quotes assert_eq!(quote.len(), 1); - assert_eq!(quote.get(0).unwrap().value, "quoted string"); + assert_eq!(quote.first().unwrap().value, "quoted string"); } // Could not load gemtext file - Err(_) => { - assert!(false); - } + Err(_) => panic!(), } }