diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index ada8a24..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ad7ed4d..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,31 +0,0 @@ -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 4d36cc5..479de04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ggemtext" -version = "0.7.0" -edition = "2024" +version = "0.1.3" +edition = "2021" license = "MIT" readme = "README.md" description = "Glib-oriented Gemtext API" @@ -15,7 +15,6 @@ categories = [ ] repository = "https://github.com/YGGverse/ggemtext" -[dependencies.glib] -package = "glib" -version = "0.21.0" -features = ["v2_66"] +[dependencies.gtk] +package = "gtk4" +version = "0.9.1" diff --git a/README.md b/README.md index 59d9f1d..5ac1f1e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # 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 @@ -14,12 +10,24 @@ 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 @@ -30,114 +38,87 @@ 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 Code::inline_from("```inline```") { + Some(inline) => assert_eq!(inline.value, "inline"), + None => assert!(false), +}; +``` + +##### 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); } - 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.level as i8, Level::H1 as i8); 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(>k::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(), 01); + 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") -``` - -## 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 + None => assert!(false), +}; +``` \ No newline at end of file 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..f9ab3ee 100644 --- a/src/line/code.rs +++ b/src/line/code.rs @@ -1,90 +1,25 @@ -pub mod error; -pub use error::Error; +pub mod inline; +pub mod multiline; -pub const TAG: &str = "```"; -pub const NEW_LINE: char = '\n'; +use inline::Inline; +use multiline::Multiline; -/// 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, + // nothing yet.. } 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 + // Inline + pub fn inline_from(line: &str) -> Option { + Inline::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(()) + // Multiline + pub fn multiline_begin_from(line: &str) -> Option { + Multiline::begin_from(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!(), + pub fn multiline_continue_from(this: &mut Multiline, line: &str) { + Multiline::continue_from(this, line) } } diff --git a/src/line/code/error.rs b/src/line/code/error.rs deleted file mode 100644 index 78d0474..0000000 --- a/src/line/code/error.rs +++ /dev/null @@ -1,16 +0,0 @@ -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 new file mode 100644 index 0000000..7baddb8 --- /dev/null +++ b/src/line/code/inline.rs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..cef1518 --- /dev/null +++ b/src/line/code/multiline.rs @@ -0,0 +1,46 @@ +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 1455963..24dc248 100644 --- a/src/line/header.rs +++ b/src/line/header.rs @@ -1,76 +1,47 @@ -pub mod gemtext; -pub mod level; +use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; -pub use gemtext::Gemtext; -pub use level::Level; +pub enum Level { + H1, + H2, + H3, +} -/// [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 { - // Constructors + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^(#{1,3})\s*(.+)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); - /// 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 - } + // Detect header level + let level = regex.get(1)?; - // Converters + let level = match level.len() { + 1 => Level::H1, + 2 => Level::H2, + 3 => Level::H3, + _ => return None, + }; - /// Convert `Self` to [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line - pub fn to_source(&self) -> String { - self.value.to_source(&self.level) + // Detect header value + let value = regex.get(2)?; + + if value.trim().is_empty() { + return None; + } + + // Result + Some(Self { + level, + value: GString::from(value.as_str()), + }) } } - -#[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..09543b3 100644 --- a/src/line/link.rs +++ b/src/line/link.rs @@ -1,119 +1,91 @@ -use glib::{DateTime, TimeZone, Uri, UriFlags}; -const S: char = ' '; +use gtk::glib::{ + DateTime, GString, Regex, RegexCompileFlags, RegexMatchFlags, TimeZone, Uri, UriFlags, +}; -pub const TAG: &str = "=>"; - -/// [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 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 } impl Link { - // Constructors + 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; - /// 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; + // 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, + } } + + // Alt + if let Some(value) = regex.get(3) { + if !value.is_empty() { + alt = Some(GString::from(value.as_str())) + } + }; + 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(), + alt, + is_external, + timestamp, + uri, }) } - - // 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 44d69f4..f2598e5 100644 --- a/src/line/list.rs +++ b/src/line/list.rs @@ -1,38 +1,29 @@ -pub mod gemtext; -pub use gemtext::Gemtext; +use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; -/// [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: String, + pub value: GString, } impl List { - // Constructors + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^\*\s*(.+)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); - /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line - pub fn parse(line: &str) -> Option { + // Detect value + let value = regex.get(1)?; + + if value.trim().is_empty() { + return None; + } + + // Result Some(Self { - value: line.as_value()?.to_string(), + 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() - } -} - -#[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..20d69b6 100644 --- a/src/line/quote.rs +++ b/src/line/quote.rs @@ -1,39 +1,29 @@ -pub mod gemtext; -pub use gemtext::Gemtext; +use gtk::glib::{GString, Regex, RegexCompileFlags, RegexMatchFlags}; -/// [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: String, + pub value: GString, } impl Quote { - // Constructors + pub fn from(line: &str) -> Option { + // Parse line + let regex = Regex::split_simple( + r"^>\s*(.+)$", + line, + RegexCompileFlags::DEFAULT, + RegexMatchFlags::DEFAULT, + ); - /// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line - pub fn parse(line: &str) -> Option { + // Detect value + let value = regex.get(1)?; + + if value.trim().is_empty() { + return None; + } + + // Result Some(Self { - value: line.as_value()?.to_string(), + 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() - } -} - -#[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..b7a7843 100644 --- a/tests/integration.gmi +++ b/tests/integration.gmi @@ -7,14 +7,12 @@ => 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 d96ccff..475398a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,9 +1,12 @@ use ggemtext::line::{ - Code, Link, List, Quote, + code::{inline::Inline, multiline::Multiline, Code}, header::{Header, Level}, + link::Link, + list::List, + quote::Quote, }; -use glib::{TimeZone, Uri, UriFlags}; +use gtk::glib::{TimeZone, Uri, UriFlags}; use std::fs; #[test] @@ -11,238 +14,204 @@ 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 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 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) = Code::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) = 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) => { + Code::multiline_continue_from(result, line); + if result.completed { + code_multiline.push(code_multiline_buffer.take().unwrap()); + code_multiline_buffer = None; } continue; } }; // Header - if let Some(result) = Header::parse(line) { - headers.push(result); + if let Some(result) = Header::from(line) { + header.push(result); continue; } // Link - if let Some(result) = Link::parse(line) { - links.push(result); + if let Some(result) = Link::from(line, base.as_ref(), timezone.as_ref()) { + link.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.get(0).unwrap().value, "inline code"); + + // Validate multiline code + assert_eq!(code_multiline.len(), 2); + { - let item = code.first().unwrap(); + let item = code_multiline.get(0).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"); + assert_eq!(item.buffer.len(), 3); + assert_eq!(item.buffer.get(0).unwrap(), "multi"); + assert_eq!(item.buffer.get(1).unwrap(), " preformatted line"); } // #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); - - let mut lines = item.value.lines(); - assert_eq!(lines.next().unwrap(), "alt-less"); - assert_eq!(lines.next().unwrap(), " preformatted line"); + assert_eq!(item.buffer.len(), 3); + assert_eq!(item.buffer.get(0).unwrap(), "alt-less"); + assert_eq!(item.buffer.get(1).unwrap(), " preformatted line"); } // #2 // Validate headers - assert_eq!(headers.len(), 3); + assert_eq!(header.len(), 3); - fn to_u8(level: &Level) -> u8 { + fn to_i8(level: &Level) -> i8 { 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)); + { + let item = header.get(0).unwrap(); + + assert_eq!(to_i8(&item.level), to_i8(&Level::H1)); assert_eq!(item.value, "H1"); } // #1 - { - let item = header.next().unwrap(); - assert_eq!(to_u8(&item.level), to_u8(&Level::H2)); + { + let item = header.get(1).unwrap(); + + assert_eq!(to_i8(&item.level), to_i8(&Level::H2)); assert_eq!(item.value, "H2"); } // #2 - { - let item = header.next().unwrap(); - assert_eq!(to_u8(&item.level), to_u8(&Level::H3)); + { + let item = header.get(2).unwrap(); + + assert_eq!(to_i8(&item.level), to_i8(&Level::H3)); assert_eq!(item.value, "H3"); } // #3 // Validate links - assert_eq!(links.len(), 9); - let mut link = links.iter(); + assert_eq!(link.len(), 5); + { - let item = link.next().unwrap(); + let item = link.get(0).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(); + let item = link.get(1).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(), 01); + 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(); + let item = link.get(2).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(); + let item = link.get(3).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(), 01); + 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(); + let item = link.get(4).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(), 01); + 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 - { - 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"); + assert_eq!(list.get(0).unwrap().value, "Listing item 1"); + assert_eq!(list.get(1).unwrap().value, "Listing item 2"); // Validate quotes assert_eq!(quote.len(), 1); - assert_eq!(quote.first().unwrap().value, "quoted string"); + assert_eq!(quote.get(0).unwrap().value, "quoted string"); } // Could not load gemtext file - Err(_) => panic!(), + Err(_) => { + assert!(false); + } } }