diff --git a/Cargo.toml b/Cargo.toml index 4d36cc5..3786d04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ggemtext" -version = "0.7.0" -edition = "2024" +version = "0.4.0" +edition = "2021" license = "MIT" readme = "README.md" description = "Glib-oriented Gemtext API" @@ -17,5 +17,5 @@ repository = "https://github.com/YGGverse/ggemtext" [dependencies.glib] package = "glib" -version = "0.21.0" +version = "0.20.9" features = ["v2_66"] diff --git a/README.md b/README.md index 59d9f1d..2002925 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ cargo add ggemtext 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::{Inline, Multiline}, + header::{Header, Level}, + link::Link, + list::List, + quote::Quote, +}; +``` + +**Prepare document** + Iterate Gemtext lines to continue with [Line](#Line) API: ``` rust @@ -30,108 +44,89 @@ 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 +##### Inline - assert!(code.is_completed); - assert_eq!(code.alt, Some("alt".into())); - assert_eq!(code.value.len(), 12 + 2); // +NL +``` rust +match Inline::from("```inline```") { + Some(inline) => assert_eq!(inline.value, "inline"), + None => assert!(false), +}; +``` + +##### Multiline + +``` rust +match Multiline::begin_from("```alt") { + Some(mut multiline) => { + assert!(Multiline::continue_from(&mut multiline, "line 1").is_ok()); + assert!(Multiline::continue_from(&mut multiline, "line 2").is_ok()); + assert!(Multiline::continue_from(&mut multiline, "```").is_ok()); // complete + + assert!(multiline.completed); + assert_eq!(multiline.alt, Some("alt".into())); + assert_eq!(multiline.buffer.len(), 3); } - None => unreachable!(), -} + None => assert!(false), +}; ``` #### Header -**Struct** - ``` rust -use ggemtext::line::{Header, header::Level}; -match Header::parse("# H1") { +match Header::from("# 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 + None => assert!(false), +}; // H1, H2, H3 ``` #### Link ``` rust -use ggemtext::line::Link; +match Link::from( + "=> gemini://geminiprotocol.net 1965-01-19 Gemini", + None, // absolute path given, base not wanted + Some(&glib::TimeZone::local()), +) { + Some(link) => { + // Alt + assert_eq!(link.alt, Some("Gemini".into())); -const SOURCE: &str = "=> gemini://geminiprotocol.net 1965-01-19 Gemini"; + // Date + match link.timestamp { + Some(timestamp) => { + assert_eq!(timestamp.year(), 1965); + assert_eq!(timestamp.month(), 1); + assert_eq!(timestamp.day_of_month(), 19); + } + 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); + // URI + assert_eq!(link.uri.to_string(), "gemini://geminiprotocol.net"); + } + None => assert!(false), +}; ``` #### List -**Struct** - ``` rust -use ggemtext::line::List; -match List::parse("* Item") { +match List::from("* 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") + None => assert!(false), +}; ``` #### Quote -**Struct** - ``` rust -use ggemtext::line::Quote; -match Quote::parse("> Quote") { +match Quote::from("> 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") + None => assert!(false), +}; ``` ## Integrations diff --git a/src/line.rs b/src/line.rs index 2cf472a..fd70850 100644 --- a/src/line.rs +++ b/src/line.rs @@ -3,9 +3,3 @@ 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 165a308..4d5e21b 100644 --- a/src/line/code.rs +++ b/src/line/code.rs @@ -1,90 +1,5 @@ -pub mod error; -pub use error::Error; +pub mod inline; +pub mod 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 { - pub alt: Option, - pub value: String, - pub is_completed: bool, -} - -impl Code { - // 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 - } - - /// 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(()) - } - - // 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!(), - } -} +pub use inline::Inline; +pub use multiline::Multiline; diff --git a/src/line/code/inline.rs b/src/line/code/inline.rs new file mode 100644 index 0000000..1148966 --- /dev/null +++ b/src/line/code/inline.rs @@ -0,0 +1,26 @@ +use glib::{Regex, RegexCompileFlags, RegexMatchFlags}; + +/// Inline [preformatted](https://geminiprotocol.net/docs/gemtext-specification.gmi#in-pre-formatted-mode) entity holder +pub struct Inline { + pub value: String, +} + +impl Inline { + // Constructors + + /// Parse `Self` from line string + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^`{3}([^`]+)`{3}$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); + + // Extract formatted value + Some(Self { + value: regex.get(1)?.trim().to_string(), + }) + } +} diff --git a/src/line/code/multiline.rs b/src/line/code/multiline.rs new file mode 100644 index 0000000..a046445 --- /dev/null +++ b/src/line/code/multiline.rs @@ -0,0 +1,58 @@ +pub mod error; +pub use error::Error; + +// Shared defaults + +pub const NEW_LINE: char = '\n'; +pub const TAG: &str = "```"; + +/// Multi-line [preformatted](https://geminiprotocol.net/docs/gemtext-specification.gmi#in-pre-formatted-mode) entity holder +pub struct Multiline { + pub alt: Option, + pub value: String, + pub completed: bool, +} + +impl Multiline { + // 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(), + completed: false, + }); + } + + None + } + + /// 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.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.completed = true; + } else { + self.value.push(NEW_LINE); + } + + Ok(()) + } +} diff --git a/src/line/code/error.rs b/src/line/code/multiline/error.rs similarity index 100% rename from src/line/code/error.rs rename to src/line/code/multiline/error.rs diff --git a/src/line/header.rs b/src/line/header.rs index 1455963..e764931 100644 --- a/src/line/header.rs +++ b/src/line/header.rs @@ -1,76 +1,40 @@ -pub mod gemtext; -pub mod level; +use glib::{Regex, RegexCompileFlags, RegexMatchFlags}; -pub use gemtext::Gemtext; -pub use level::Level; +/// [Header](https://geminiprotocol.net/docs/gemtext-specification.gmi#heading-lines) type holder +pub enum Level { + H1, + H2, + H3, +} /// [Header](https://geminiprotocol.net/docs/gemtext-specification.gmi#heading-lines) entity holder pub struct Header { - pub level: Level, pub value: String, + pub level: Level, } impl Header { // Constructors /// 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 - } + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^(#{1,3})\s*(.+)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); - // 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) + // Result + Some(Self { + level: match regex.get(1)?.len() { + 1 => Level::H1, + 2 => Level::H2, + 3 => Level::H3, + _ => return None, + }, + value: regex.get(2)?.trim().to_string(), + }) } } - -#[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 deleted file mode 100644 index 283edbb..0000000 --- a/src/line/header/gemtext.rs +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 2fa001b..0000000 --- a/src/line/header/level.rs +++ /dev/null @@ -1,16 +0,0 @@ -/// [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 0d2aff9..29bbab5 100644 --- a/src/line/link.rs +++ b/src/line/link.rs @@ -1,119 +1,96 @@ -use glib::{DateTime, TimeZone, Uri, UriFlags}; -const S: char = ' '; - -pub const TAG: &str = "=>"; +use glib::{DateTime, Regex, RegexCompileFlags, RegexMatchFlags, TimeZone, Uri, UriFlags}; /// [Link](https://geminiprotocol.net/docs/gemtext-specification.gmi#link-lines) entity holder pub struct Link { - /// 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, + pub alt: Option, // [optional] alternative link description + pub timestamp: Option, // [optional] valid link DateTime object + pub uri: Uri, // [required] valid link URI object } impl Link { // Constructors /// 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; - } - Some(Self { - alt: l - .get(u.len()..) - .map(|a| a.trim()) - .filter(|a| !a.is_empty()) - .map(|a| a.to_string()), - url: u.to_string(), - }) - } + pub fn from(line: &str, base: Option<&Uri>, timezone: Option<&TimeZone>) -> Option { + // Define initial values + let mut alt = None; + let mut timestamp = None; - // 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, + // Begin line parse + let regex = Regex::split_simple( + r"^=>\s*([^\s]+)\s*(\d{4}-\d{2}-\d{2})?\s*(.+)?$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, ); - 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 + // Detect address required to continue + let mut unresolved_address = regex.get(1)?.to_string(); - /// 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(), + if let Some(p) = unresolved_address.strip_prefix("//") { + let b = base?; + let postfix = p.trim_start_matches(":"); + unresolved_address = format!( + "{}://{}", + b.scheme(), + if postfix.is_empty() { + format!("{}/", b.host()?) + } else { + postfix.into() + } + ) } + // 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) => resolved_uri, + Err(_) => return None, + } + } + Err(_) => return None, + } + } + // Base resolve not requested + None => { + // 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) { + timestamp = match DateTime::from_iso8601(&format!("{date}T00:00:00"), timezone) { + Ok(value) => Some(value), + Err(_) => None, + } + } + + // Alt + if let Some(value) = regex.get(3) { + if !value.is_empty() { + alt = Some(value.to_string()) + } + }; + + Some(Self { + alt, + timestamp, + uri, + }) } } - -#[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 44d69f4..d9959f2 100644 --- a/src/line/list.rs +++ b/src/line/list.rs @@ -1,8 +1,4 @@ -pub mod gemtext; -pub use gemtext::Gemtext; - -/// [List item](https://geminiprotocol.net/docs/gemtext-specification.gmi#list-items) tag -pub const TAG: char = '*'; +use glib::{Regex, RegexCompileFlags, RegexMatchFlags}; /// [List](https://geminiprotocol.net/docs/gemtext-specification.gmi#list-items) entity holder pub struct List { @@ -12,27 +8,19 @@ pub struct List { impl List { // Constructors - /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line - pub fn parse(line: &str) -> Option { + /// Parse `Self` from line string + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^\*\s*(.*)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); + + // Extract formatted value Some(Self { - value: line.as_value()?.to_string(), + value: regex.get(1)?.trim().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 deleted file mode 100644 index 5700287..0000000 --- a/src/line/list/gemtext.rs +++ /dev/null @@ -1,26 +0,0 @@ -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 e098703..8926165 100644 --- a/src/line/quote.rs +++ b/src/line/quote.rs @@ -1,8 +1,4 @@ -pub mod gemtext; -pub use gemtext::Gemtext; - -/// [Quote item](https://geminiprotocol.net/docs/gemtext-specification.gmi#quote-lines) tag -pub const TAG: char = '>'; +use glib::{Regex, RegexCompileFlags, RegexMatchFlags}; /// [Quote](https://geminiprotocol.net/docs/gemtext-specification.gmi#quote-lines) entity holder pub struct Quote { @@ -12,28 +8,19 @@ pub struct Quote { impl Quote { // Constructors - /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line - pub fn parse(line: &str) -> Option { + /// Parse `Self` from line string + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^>\s*(.*)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); + + // Extract formatted value Some(Self { - value: line.as_value()?.to_string(), + value: regex.get(1)?.trim().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 deleted file mode 100644 index e55d2f3..0000000 --- a/src/line/quote/gemtext.rs +++ /dev/null @@ -1,26 +0,0 @@ -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 400695a..a33000f 100644 --- a/tests/integration.gmi +++ b/tests/integration.gmi @@ -15,6 +15,8 @@ * Listing item 1 * Listing item 2 +```inline code``` + ``` alt text multi preformatted line diff --git a/tests/integration.rs b/tests/integration.rs index d96ccff..6c28f35 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,9 @@ use ggemtext::line::{ - Code, Link, List, Quote, + code::{Inline, Multiline}, header::{Header, Level}, + link::Link, + list::List, + quote::Quote, }; use glib::{TimeZone, Uri, UriFlags}; @@ -11,69 +14,85 @@ fn gemtext() { match fs::read_to_string("tests/integration.gmi") { Ok(gemtext) => { // Init tags collection - let mut code: Vec = Vec::new(); + let mut code_inline: Vec = Vec::new(); + let mut code_multiline: 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; + let mut code_multiline_buffer: Option = None; // Define base URI as integration.gmi contain one relative link - let base = Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE).unwrap(); + let base = match Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE) { + Ok(uri) => Some(uri), + Err(_) => None, + }; // Define timezone as integration.gmi contain one links with date - let timezone = TimeZone::local(); + let timezone = Some(TimeZone::local()); // Parse document by line for line in gemtext.lines() { - match code_buffer { + // Inline code + if let Some(result) = Inline::from(line) { + code_inline.push(result); + continue; + } + + // Multiline code + match code_multiline_buffer { None => { - if let Some(code) = Code::begin_from(line) { - code_buffer = Some(code); + if let Some(code) = Multiline::begin_from(line) { + code_multiline_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; + Some(ref mut result) => { + assert!(Multiline::continue_from(result, line).is_ok()); + if result.completed { + code_multiline.push(code_multiline_buffer.take().unwrap()); + code_multiline_buffer = None; } continue; } }; // Header - if let Some(result) = Header::parse(line) { + if let Some(result) = Header::from(line) { headers.push(result); continue; } // Link - if let Some(result) = Link::parse(line) { + if let Some(result) = Link::from(line, base.as_ref(), timezone.as_ref()) { links.push(result); continue; } // List - if let Some(result) = List::parse(line) { + if let Some(result) = List::from(line) { list.push(result); continue; } // Quote - if let Some(result) = Quote::parse(line) { + if let Some(result) = Quote::from(line) { quote.push(result); continue; } } - // Validate code - assert_eq!(code.len(), 2); + // Validate inline code + assert_eq!(code_inline.len(), 1); + assert_eq!(code_inline.first().unwrap().value, "inline code"); + + // Validate multiline code + assert_eq!(code_multiline.len(), 2); + { - let item = code.first().unwrap(); + let item = code_multiline.first().unwrap(); assert_eq!(item.alt.clone().unwrap(), "alt text"); assert_eq!(item.value.lines().count(), 2); @@ -84,7 +103,7 @@ fn gemtext() { } // #1 { - let item = code.get(1).unwrap(); + let item = code_multiline.get(1).unwrap(); assert_eq!(item.alt.clone(), None); assert_eq!(item.value.lines().count(), 2); @@ -131,64 +150,52 @@ fn gemtext() { 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" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); } // #1 { let item = link.next().unwrap(); - assert_eq!( - item.uri(Some(&base)).unwrap().to_string(), - "gemini://geminiprotocol.net" - ); + assert_eq!(item.alt, None); - let time = item.time(Some(&timezone)).unwrap(); - assert_eq!(time.year(), 1965); - assert_eq!(time.month(), 1); - assert_eq!(time.day_of_month(), 19); + let timestamp = item.timestamp.clone().unwrap(); + assert_eq!(timestamp.year(), 1965); + assert_eq!(timestamp.month(), 1); + assert_eq!(timestamp.day_of_month(), 19); - assert_eq!(item.alt, Some("1965-01-19".to_string())); + assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); } // #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" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); } // #3 { let item = link.next().unwrap(); - assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string())); + assert_eq!(item.alt.clone().unwrap(), "Gemini"); - let time = item.time(Some(&timezone)).unwrap(); - assert_eq!(time.year(), 1965); - assert_eq!(time.month(), 1); - assert_eq!(time.day_of_month(), 19); + let timestamp = item.timestamp.clone().unwrap(); + assert_eq!(timestamp.year(), 1965); + assert_eq!(timestamp.month(), 1); + assert_eq!(timestamp.day_of_month(), 19); - assert_eq!( - item.uri(Some(&base)).unwrap().to_string(), - "gemini://geminiprotocol.net" - ); + assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net"); } // #4 { let item = link.next().unwrap(); - assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string())); + assert_eq!(item.alt.clone().unwrap(), "Gemini"); - let time = item.time(Some(&timezone)).unwrap(); - assert_eq!(time.year(), 1965); - assert_eq!(time.month(), 1); - assert_eq!(time.day_of_month(), 19); + let timestamp = item.timestamp.clone().unwrap(); + assert_eq!(timestamp.year(), 1965); + assert_eq!(timestamp.month(), 1); + assert_eq!(timestamp.day_of_month(), 19); assert_eq!( - item.uri(Some(&base)).unwrap().to_string(), + item.uri.to_str(), "gemini://geminiprotocol.net/docs/gemtext.gmi" ); } // #5 @@ -196,41 +203,29 @@ fn gemtext() { 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" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "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" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "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" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "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/" - ); + assert_eq!(item.timestamp, None); + assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net/"); } // #9 // Validate lists