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 55e4789..4d36cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ggemtext" -version = "0.1.1" -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 b3dbcb1..59d9f1d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,143 @@ # 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 + +``` bash +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. + +Iterate Gemtext lines to continue with [Line](#Line) API: + +``` rust +for line in gemtext.lines() { + // .. +} +``` + +#### Code + +``` rust +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 + + assert!(code.is_completed); + assert_eq!(code.alt, Some("alt".into())); + assert_eq!(code.value.len(), 12 + 2); // +NL + } + None => unreachable!(), +} +``` + +#### Header + +**Struct** + +``` rust +use ggemtext::line::{Header, header::Level}; +match Header::parse("# H1") { + Some(h1) => { + assert_eq!(h1.level as u8, Level::H1 as u8); + assert_eq!(h1.value, "H1"); + } + 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 +use ggemtext::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); +``` + +#### List + +**Struct** + +``` rust +use ggemtext::line::List; +match List::parse("* Item") { + Some(list) => assert_eq!(list.value, "Item"), + 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 +use ggemtext::line::Quote; +match Quote::parse("> Quote") { + Some(quote) => assert_eq!(quote.value, "Quote"), + 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/lib.rs b/src/lib.rs index da546e7..7f6b32b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,87 +1 @@ pub mod line; - -#[cfg(test)] -mod tests { - use super::line::{ - code::Code, - header::{Header, Level}, - link::Link, - list::List, - quote::Quote, - }; - - #[test] - fn line() { - // Code - match Code::inline_from("```inline```") { - Some(inline) => { - assert_eq!(inline.value, "inline"); - } - None => assert!(false), - }; - - 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, "```"); - - assert!(multiline.completed); - assert_eq!(multiline.alt, Some("alt".into())); - assert_eq!(multiline.buffer.len(), 3); - } - None => assert!(false), - }; - - // Header - match Header::from("# H1") { - Some(h1) => { - assert_eq!(h1.level as i8, Level::H1 as i8); - assert_eq!(h1.value, "H1"); - } - None => assert!(false), - }; - - match Header::from("## H2") { - Some(h2) => { - assert_eq!(h2.level as i8, Level::H2 as i8); - assert_eq!(h2.value, "H2"); - } - None => assert!(false), - }; - - match Header::from("### H3") { - Some(h3) => { - assert_eq!(h3.level as i8, Level::H3 as i8); - assert_eq!(h3.value, "H3"); - } - None => assert!(false), - }; - - // Link - match Link::from("=> gemini://geminiprotocol.net Gemini", None, None) { - Some(link) => { - assert_eq!(link.alt, Some("Gemini".into())); - assert_eq!(link.uri.to_string(), "gemini://geminiprotocol.net"); - // @TODO timestamp - } - None => assert!(false), - }; // @TODO options - - // List - match List::from("* Item") { - Some(list) => { - assert_eq!(list.value, "Item"); - } - None => assert!(false), - }; - - // Quote - match Quote::from("> Quote") { - Some(quote) => { - assert_eq!(quote.value, "Quote"); - } - None => assert!(false), - }; - } -} 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 af92cf6..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("```"); - - return Some(Self { - alt: match alt.trim().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 b1d99e2..0d2aff9 100644 --- a/src/line/link.rs +++ b/src/line/link.rs @@ -1,89 +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) { - 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 new file mode 100644 index 0000000..400695a --- /dev/null +++ b/tests/integration.gmi @@ -0,0 +1,33 @@ +# H1 +## H2 +### H3 + +=> gemini://geminiprotocol.net +=> gemini://geminiprotocol.net 1965-01-19 +=> 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 + +``` alt text +multi + preformatted line +``` + +``` +alt-less + preformatted line +``` + +> quoted string + +paragraph line 1 +paragraph line 2 + +paragraph 2 \ No newline at end of file diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..d96ccff --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,248 @@ +use ggemtext::line::{ + Code, Link, List, Quote, + header::{Header, Level}, +}; + +use glib::{TimeZone, Uri, UriFlags}; +use std::fs; + +#[test] +fn gemtext() { + match fs::read_to_string("tests/integration.gmi") { + Ok(gemtext) => { + // Init tags collection + 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_buffer: Option = None; + + // Define base URI as integration.gmi contain one relative link + let base = Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE).unwrap(); + + // Define timezone as integration.gmi contain one links with date + let timezone = TimeZone::local(); + + // Parse document by line + for line in gemtext.lines() { + match code_buffer { + None => { + if let Some(code) = Code::begin_from(line) { + code_buffer = Some(code); + continue; + } + } + 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::parse(line) { + headers.push(result); + continue; + } + + // Link + if let Some(result) = Link::parse(line) { + links.push(result); + continue; + } + + // List + if let Some(result) = List::parse(line) { + list.push(result); + continue; + } + + // Quote + if let Some(result) = Quote::parse(line) { + quote.push(result); + continue; + } + } + + // Validate code + assert_eq!(code.len(), 2); + { + let item = code.first().unwrap(); + assert_eq!(item.alt.clone().unwrap(), "alt text"); + + 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.get(1).unwrap(); + assert_eq!(item.alt.clone(), None); + + 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!(headers.len(), 3); + + 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.next().unwrap(); + + assert_eq!(to_u8(&item.level), to_u8(&Level::H1)); + assert_eq!(item.value, "H1"); + } // #1 + { + let item = header.next().unwrap(); + + assert_eq!(to_u8(&item.level), to_u8(&Level::H2)); + assert_eq!(item.value, "H2"); + } // #2 + { + let item = header.next().unwrap(); + + assert_eq!(to_u8(&item.level), to_u8(&Level::H3)); + assert_eq!(item.value, "H3"); + } // #3 + + // Validate links + assert_eq!(links.len(), 9); + let mut link = links.iter(); + { + 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_str(), + "gemini://geminiprotocol.net" + ); + } // #1 + { + let item = link.next().unwrap(); + + assert_eq!( + 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.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.first().unwrap().value, "quoted string"); + } + // Could not load gemtext file + Err(_) => panic!(), + } +}