Compare commits

...

55 commits
0.3.0 ... main

Author SHA1 Message Date
yggverse
2a51b67387 update glib version to 0.21.0, update crate version to 0.7.0 2025-07-23 05:08:52 +03:00
yggverse
f83d049c60 update version 2025-03-28 00:30:15 +02:00
yggverse
2d98b66d82 fix clippy 2025-03-21 17:47:43 +02:00
yggverse
459626acb4 update minor version 2025-03-21 17:03:10 +02:00
yggverse
ff70e5410a update examples 2025-03-21 17:02:58 +02:00
yggverse
678f906f48 remove unspecified inline code tag support 2025-03-21 17:02:50 +02:00
yggverse
10ff400d8f update version 2025-03-18 01:05:23 +02:00
yggverse
25a45337ff trim members 2025-03-17 22:23:07 +02:00
yggverse
841ee2036e fix namespace example 2025-03-17 21:42:09 +02:00
yggverse
22a05a975c remove regex dependency, rename constructor, add tests 2025-03-17 21:39:07 +02:00
yggverse
0c90bbafba update headers 2025-03-17 02:46:13 +02:00
yggverse
0c6ba0c87c add trait example 2025-03-17 02:42:22 +02:00
yggverse
92e1557c9c update readme 2025-03-17 02:39:26 +02:00
yggverse
96f7d648b6 remove regex dependency, rename constructor, implement zero-copy trait 2025-03-17 02:09:52 +02:00
yggverse
7f3ea670f1 add new line separator 2025-03-16 19:54:36 +02:00
yggverse
83ec663929 skip regex operations on tag mismatch subject 2025-03-16 19:53:29 +02:00
yggverse
7345400172 update readme 2025-03-16 19:43:15 +02:00
yggverse
8eaad1dac9 enshort var name 2025-03-16 18:50:56 +02:00
yggverse
a5638fde33 implement tests 2025-03-16 18:46:12 +02:00
yggverse
d72575fdc5 simplify 2025-03-16 17:55:38 +02:00
yggverse
eedd7a73ff trim once 2025-03-16 17:53:03 +02:00
yggverse
039b1db935 test Level member 2025-03-16 17:37:20 +02:00
yggverse
f550041b55 implement test 2025-03-16 17:28:56 +02:00
yggverse
9392b39327 remove duplicated header anchors 2025-03-16 16:44:58 +02:00
yggverse
3e16995e00 reorganize header component 2025-03-16 16:39:02 +02:00
yggverse
c29f1ba529 separate traits 2025-03-16 16:38:38 +02:00
yggverse
5b751e3c7a change result data type to &str 2025-03-16 15:53:24 +02:00
yggverse
7802869d0d remove regex dependency, rename constructor, implement Gemtext trait 2025-03-16 15:43:27 +02:00
yggverse
9d27cdfb49 update readme 2025-03-16 14:45:13 +02:00
yggverse
bf4ac4bd27 rename constructor, implement zero-copy trait, remove extra regex parser 2025-03-16 14:43:08 +02:00
yggverse
1b43f6aeaf fix method name, add missed test condition 2025-03-16 13:47:00 +02:00
yggverse
9696efa02d update versions 2025-03-16 13:43:02 +02:00
yggverse
23b04f26ec update examples 2025-03-16 13:42:38 +02:00
yggverse
4ce1b20bf7 rename constructor, implement zero-copy trait, remove extra regex parser 2025-03-16 13:42:23 +02:00
yggverse
bab4e03940 define child namespaces 2025-03-16 13:12:59 +02:00
yggverse
7826104978 update version 2025-03-15 17:08:51 +02:00
yggverse
bafdcda7be update tests 2025-03-15 14:57:37 +02:00
yggverse
085ec164b8 update dependencies version 2025-03-15 14:44:10 +02:00
yggverse
4d2c05c428 update minor version 2025-03-15 13:57:14 +02:00
yggverse
638d3cff08 remove is_external detection from crate level 2025-03-15 13:56:40 +02:00
yggverse
2870aeb3fb add ending slash, reorganize conditions 2025-03-15 13:55:27 +02:00
yggverse
0364760a35 update condition 2025-03-14 22:57:05 +02:00
yggverse
094a404cf0 update base condition 2025-03-14 22:48:15 +02:00
yggverse
ffcf8f9627 fix relative scheme resolve 2025-03-14 22:45:35 +02:00
yggverse
7c2051acaf use u8 2025-02-16 22:40:25 +02:00
yggverse
b37a5f9061 update version 2025-02-16 22:36:41 +02:00
yggverse
f2fbced415 add missed dependencies 2025-02-15 21:12:47 +02:00
yggverse
407c3e2e13 add funding info 2025-02-15 21:10:58 +02:00
yggverse
f3dc550c2e fix new lines skip condition 2025-02-15 21:07:01 +02:00
yggverse
e1cb4f9b99 update readme 2024-12-21 20:30:08 +02:00
yggverse
c4d92d8d1e add badge 2024-12-20 10:20:21 +02:00
yggverse
4ea42809d7 rename workflow 2024-12-20 10:20:10 +02:00
yggverse
9f7b85b523 fix clippy warnings 2024-12-07 23:08:41 +02:00
yggverse
8c5e806bdc validate warnings 2024-12-07 22:51:20 +02:00
yggverse
a9fed67a71 update version 2024-12-05 06:36:36 +02:00
19 changed files with 709 additions and 421 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://yggverse.github.io/#donate

View file

@ -1,4 +1,4 @@
name: Rust
name: Build
on:
push:
@ -8,6 +8,7 @@ on:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
jobs:
build:
@ -18,6 +19,10 @@ jobs:
- 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

View file

@ -1,7 +1,7 @@
[package]
name = "ggemtext"
version = "0.3.0"
edition = "2021"
version = "0.7.0"
edition = "2024"
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.20.4"
version = "0.21.0"
features = ["v2_66"]

143
README.md
View file

@ -1,5 +1,9 @@
# ggemtext
![Build](https://github.com/YGGverse/ggemtext/actions/workflows/build.yml/badge.svg)
[![Documentation](https://docs.rs/ggemtext/badge.svg)](https://docs.rs/ggemtext)
[![crates.io](https://img.shields.io/crates/v/ggemtext.svg)](https://crates.io/crates/ggemtext)
Glib-oriented [Gemtext](https://geminiprotocol.net/docs/gemtext.gmi) API
## Install
@ -16,20 +20,6 @@ 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
@ -40,89 +30,108 @@ for line in gemtext.lines() {
#### Code
##### Inline
``` rust
match Inline::from("```inline```") {
Some(inline) => assert_eq!(inline.value, "inline"),
None => assert!(false),
};
```
use ggemtext::line::Code;
match Code::begin_from("```alt") {
Some(mut code) => {
assert!(code.continue_from("line 1").is_ok());
assert!(code.continue_from("line 2").is_ok());
assert!(code.continue_from("```").is_ok()); // complete
##### Multiline
``` rust
match 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);
assert!(code.is_completed);
assert_eq!(code.alt, Some("alt".into()));
assert_eq!(code.value.len(), 12 + 2); // +NL
}
None => assert!(false),
};
None => unreachable!(),
}
```
#### Header
**Struct**
``` rust
match Header::from("# H1") {
use ggemtext::line::{Header, header::Level};
match Header::parse("# H1") {
Some(h1) => {
assert_eq!(h1.level as i8, Level::H1 as i8);
assert_eq!(h1.level as u8, Level::H1 as u8);
assert_eq!(h1.value, "H1");
}
None => assert!(false),
}; // H1, H2, H3
None => unreachable!(),
} // H1, H2, H3
```
**Trait**
``` rust
use ggemtext::line::header::{Gemtext, Level};
assert_eq!("# H1".as_value(), Some("H1"));
assert_eq!("H1".to_source(&Level::H1), "# H1");
// H1, H2, H3
```
#### Link
``` rust
match Link::from(
"=> gemini://geminiprotocol.net 1965-01-19 Gemini",
None, // absolute path given, base not wanted
Some(&glib::TimeZone::local()),
) {
Some(link) => {
// Alt
assert_eq!(link.alt, Some("Gemini".into()));
use ggemtext::line::Link;
// Date
match link.timestamp {
Some(timestamp) => {
assert_eq!(timestamp.year(), 1965);
assert_eq!(timestamp.month(), 01);
assert_eq!(timestamp.day_of_month(), 19);
}
None => assert!(false),
}
const SOURCE: &str = "=> gemini://geminiprotocol.net 1965-01-19 Gemini";
// URI
assert_eq!(link.uri.to_string(), "gemini://geminiprotocol.net");
}
None => assert!(false),
};
let link = Link::parse(SOURCE).unwrap();
assert_eq!(link.alt, Some("1965-01-19 Gemini".to_string()));
assert_eq!(link.url, "gemini://geminiprotocol.net");
let uri = link.uri(None).unwrap();
assert_eq!(uri.scheme(), "gemini");
assert_eq!(uri.host().unwrap(), "geminiprotocol.net");
let time = link.time(Some(&glib::TimeZone::local())).unwrap();
assert_eq!(time.year(), 1965);
assert_eq!(time.month(), 1);
assert_eq!(time.day_of_month(), 19);
assert_eq!(link.to_source(), SOURCE);
```
#### List
**Struct**
``` rust
match List::from("* Item") {
use ggemtext::line::List;
match List::parse("* Item") {
Some(list) => assert_eq!(list.value, "Item"),
None => assert!(false),
};
None => unreachable!(),
}
```
**Trait**
``` rust
use ggemtext::line::list::Gemtext;
assert_eq!("* Item".as_value(), Some("Item"))
assert_eq!("Item".to_source(), "* Item")
```
#### Quote
**Struct**
``` rust
match Quote::from("> Quote") {
use ggemtext::line::Quote;
match Quote::parse("> Quote") {
Some(quote) => assert_eq!(quote.value, "Quote"),
None => assert!(false),
};
None => unreachable!(),
}
```
**Trait**
``` rust
use ggemtext::line::quote::Gemtext;
assert_eq!("> Quote".as_value(), Some("Quote"))
assert_eq!("Quote".to_source(), "> Quote")
```
## Integrations

View file

@ -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;

View file

@ -1,5 +1,90 @@
pub mod inline;
pub mod multiline;
pub mod error;
pub use error::Error;
pub use inline::Inline;
pub 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 {
pub alt: Option<String>,
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<Self> {
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!(),
}
}

View file

@ -1,26 +0,0 @@
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<Self> {
// 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(),
})
}
}

View file

@ -1,61 +0,0 @@
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<String>,
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<Self> {
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);
}
// Line contain close tag
if line.ends_with(TAG) {
self.completed = true;
}
// Prepend new line before next lines only
if !self.value.is_empty() {
self.value.push(NEW_LINE);
}
// Append to value, trim close tag on exists
self.value.push_str(line.trim_end_matches(TAG));
Ok(())
}
}

View file

@ -1,40 +1,76 @@
use glib::{Regex, RegexCompileFlags, RegexMatchFlags};
pub mod gemtext;
pub mod level;
/// [Header](https://geminiprotocol.net/docs/gemtext-specification.gmi#heading-lines) type holder
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: String,
pub level: Level,
pub value: String,
}
impl Header {
// Constructors
/// Parse `Self` from line string
pub fn from(line: &str) -> Option<Self> {
// Parse line
let regex = Regex::split_simple(
r"^(#{1,3})\s*(.+)$",
line,
RegexCompileFlags::DEFAULT,
RegexMatchFlags::DEFAULT,
);
pub fn parse(line: &str) -> Option<Self> {
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: match regex.get(1)?.len() {
1 => Level::H1,
2 => Level::H2,
3 => Level::H3,
_ => return None,
},
value: regex.get(2)?.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(&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())
}

View file

@ -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<Level>;
/// 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<Level> {
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);
}

16
src/line/header/level.rs Normal file
View file

@ -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 => "###",
}
}
}

View file

@ -1,101 +1,119 @@
use glib::{DateTime, 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<String>, // [optional] alternative link description
pub is_external: Option<bool>, // [optional] external link indication, on base option provided
pub timestamp: Option<DateTime>, // [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<String>,
/// 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 {
// Constructors
/// Parse `Self` from line string
pub fn from(line: &str, base: Option<&Uri>, timezone: Option<&TimeZone>) -> Option<Self> {
// Define initial values
let mut alt = None;
let mut timestamp = None;
let mut is_external = 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 mut unresolved_address = regex.get(1)?.to_string();
// Seems that [Uri resolver](https://docs.gtk.org/glib/type_func.Uri.resolve_relative.html)
// does not support [protocol-relative URI](https://datatracker.ietf.org/doc/html/rfc3986#section-4.2)
// resolve manually
if unresolved_address.starts_with("//:") {
let scheme = match base {
Some(base) => base.scheme(),
None => return None,
};
unresolved_address = unresolved_address.replace("//:", &format!("{scheme}://"));
pub fn parse(line: &str) -> Option<Self> {
let l = line.strip_prefix(TAG)?.trim();
let u = l.find(S).map_or(l, |i| &l[..i]);
if u.is_empty() {
return None;
}
// 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 => {
// 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,
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<DateTime> {
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<Uri> {
// 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);
}

View file

@ -1,4 +1,8 @@
use glib::{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 {
@ -8,19 +12,27 @@ pub struct List {
impl List {
// Constructors
/// Parse `Self` from line string
pub fn from(line: &str) -> Option<Self> {
// Parse line
let regex = Regex::split_simple(
r"^\*\s*(.*)$",
line,
RegexCompileFlags::DEFAULT,
RegexMatchFlags::DEFAULT,
);
// Extract formatted value
/// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line
pub fn parse(line: &str) -> Option<Self> {
Some(Self {
value: regex.get(1)?.trim().to_string(),
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);
}

26
src/line/list/gemtext.rs Normal file
View file

@ -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)
}

View file

@ -1,4 +1,8 @@
use glib::{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 {
@ -8,19 +12,28 @@ pub struct Quote {
impl Quote {
// Constructors
/// Parse `Self` from line string
pub fn from(line: &str) -> Option<Self> {
// Parse line
let regex = Regex::split_simple(
r"^>\s*(.*)$",
line,
RegexCompileFlags::DEFAULT,
RegexMatchFlags::DEFAULT,
);
// Extract formatted value
/// Parse `Self` from [Gemtext](https://geminiprotocol.net/docs/gemtext-specification.gmi) line
pub fn parse(line: &str) -> Option<Self> {
Some(Self {
value: regex.get(1)?.trim().to_string(),
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);
}

26
src/line/quote/gemtext.rs Normal file
View file

@ -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)
}

View file

@ -8,12 +8,13 @@
=> 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

View file

@ -1,9 +1,6 @@
use ggemtext::line::{
code::{Inline, Multiline},
Code, Link, List, Quote,
header::{Header, Level},
link::Link,
list::List,
quote::Quote,
};
use glib::{TimeZone, Uri, UriFlags};
@ -14,212 +11,238 @@ fn gemtext() {
match fs::read_to_string("tests/integration.gmi") {
Ok(gemtext) => {
// Init tags collection
let mut code_inline: Vec<Inline> = Vec::new();
let mut code_multiline: Vec<Multiline> = Vec::new();
let mut header: Vec<Header> = Vec::new();
let mut link: Vec<Link> = Vec::new();
let mut code: Vec<Code> = Vec::new();
let mut headers: Vec<Header> = Vec::new();
let mut links: Vec<Link> = Vec::new();
let mut list: Vec<List> = Vec::new();
let mut quote: Vec<Quote> = Vec::new();
// Define preformatted buffer
let mut code_multiline_buffer: Option<Multiline> = None;
let mut code_buffer: Option<Code> = None;
// Define base URI as integration.gmi contain one relative link
let base = match Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE) {
Ok(uri) => Some(uri),
Err(_) => None,
};
let base = Uri::parse("gemini://geminiprotocol.net", UriFlags::NONE).unwrap();
// Define timezone as integration.gmi contain one links with date
let timezone = Some(TimeZone::local());
let timezone = TimeZone::local();
// Parse document by line
for line in gemtext.lines() {
// Inline code
if let Some(result) = Inline::from(line) {
code_inline.push(result);
continue;
}
// Multiline code
match code_multiline_buffer {
match code_buffer {
None => {
if let Some(code) = Multiline::begin_from(line) {
code_multiline_buffer = Some(code);
if let Some(code) = Code::begin_from(line) {
code_buffer = Some(code);
continue;
}
}
Some(ref mut result) => {
assert!(Multiline::continue_from(result, line).is_ok());
if result.completed {
code_multiline.push(code_multiline_buffer.take().unwrap());
code_multiline_buffer = None;
Some(ref mut c) => {
assert!(c.continue_from(line).is_ok());
if c.is_completed {
code.push(code_buffer.take().unwrap());
code_buffer = None;
}
continue;
}
};
// Header
if let Some(result) = Header::from(line) {
header.push(result);
if let Some(result) = Header::parse(line) {
headers.push(result);
continue;
}
// Link
if let Some(result) = Link::from(line, base.as_ref(), timezone.as_ref()) {
link.push(result);
if let Some(result) = Link::parse(line) {
links.push(result);
continue;
}
// List
if let Some(result) = List::from(line) {
if let Some(result) = List::parse(line) {
list.push(result);
continue;
}
// Quote
if let Some(result) = Quote::from(line) {
if let Some(result) = Quote::parse(line) {
quote.push(result);
continue;
}
}
// Validate inline code
assert_eq!(code_inline.len(), 1);
assert_eq!(code_inline.get(0).unwrap().value, "inline code");
// Validate multiline code
assert_eq!(code_multiline.len(), 2);
// Validate code
assert_eq!(code.len(), 2);
{
let item = code_multiline.get(0).unwrap();
let item = code.first().unwrap();
assert_eq!(item.alt.clone().unwrap(), "alt text");
assert_eq!(item.value.lines().count(), 2);
assert_eq!(item.value.lines().nth(0).unwrap(), "multi");
assert_eq!(item.value.lines().nth(1).unwrap(), " preformatted line");
let mut lines = item.value.lines();
assert_eq!(lines.next().unwrap(), "multi");
assert_eq!(lines.next().unwrap(), " preformatted line");
} // #1
{
let item = code_multiline.get(1).unwrap();
let item = code.get(1).unwrap();
assert_eq!(item.alt.clone(), None);
assert_eq!(item.value.lines().count(), 2);
assert_eq!(item.value.lines().nth(0).unwrap(), "alt-less");
assert_eq!(item.value.lines().nth(1).unwrap(), " preformatted line");
let mut lines = item.value.lines();
assert_eq!(lines.next().unwrap(), "alt-less");
assert_eq!(lines.next().unwrap(), " preformatted line");
} // #2
// Validate headers
assert_eq!(header.len(), 3);
assert_eq!(headers.len(), 3);
fn to_i8(level: &Level) -> i8 {
fn to_u8(level: &Level) -> u8 {
match level {
Level::H1 => 1,
Level::H2 => 2,
Level::H3 => 3,
}
} // comparison helper
let mut header = headers.iter();
{
let item = header.get(0).unwrap();
let item = header.next().unwrap();
assert_eq!(to_i8(&item.level), to_i8(&Level::H1));
assert_eq!(to_u8(&item.level), to_u8(&Level::H1));
assert_eq!(item.value, "H1");
} // #1
{
let item = header.get(1).unwrap();
let item = header.next().unwrap();
assert_eq!(to_i8(&item.level), to_i8(&Level::H2));
assert_eq!(to_u8(&item.level), to_u8(&Level::H2));
assert_eq!(item.value, "H2");
} // #2
{
let item = header.get(2).unwrap();
let item = header.next().unwrap();
assert_eq!(to_i8(&item.level), to_i8(&Level::H3));
assert_eq!(to_u8(&item.level), to_u8(&Level::H3));
assert_eq!(item.value, "H3");
} // #3
// Validate links
assert_eq!(link.len(), 6);
assert_eq!(links.len(), 9);
let mut link = links.iter();
{
let item = link.get(0).unwrap();
let item = link.next().unwrap();
assert_eq!(item.alt, None);
assert_eq!(item.timestamp, None);
assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net");
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_str(),
"gemini://geminiprotocol.net"
);
} // #1
{
let item = link.get(1).unwrap();
assert_eq!(item.alt, None);
let timestamp = item.timestamp.clone().unwrap();
assert_eq!(timestamp.year(), 1965);
assert_eq!(timestamp.month(), 01);
assert_eq!(timestamp.day_of_month(), 19);
assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net");
} // #2
{
let item = link.get(2).unwrap();
assert_eq!(item.alt.clone().unwrap(), "Gemini");
assert_eq!(item.timestamp, None);
assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net");
} // #3
{
let item = link.get(3).unwrap();
assert_eq!(item.alt.clone().unwrap(), "Gemini");
let timestamp = item.timestamp.clone().unwrap();
assert_eq!(timestamp.year(), 1965);
assert_eq!(timestamp.month(), 01);
assert_eq!(timestamp.day_of_month(), 19);
assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net");
} // #4
{
let item = link.get(4).unwrap();
assert_eq!(item.alt.clone().unwrap(), "Gemini");
let timestamp = item.timestamp.clone().unwrap();
assert_eq!(timestamp.year(), 1965);
assert_eq!(timestamp.month(), 01);
assert_eq!(timestamp.day_of_month(), 19);
let item = link.next().unwrap();
assert_eq!(
item.uri.to_str(),
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net"
);
let time = item.time(Some(&timezone)).unwrap();
assert_eq!(time.year(), 1965);
assert_eq!(time.month(), 1);
assert_eq!(time.day_of_month(), 19);
assert_eq!(item.alt, Some("1965-01-19".to_string()));
} // #2
{
let item = link.next().unwrap();
assert_eq!(item.alt.clone().unwrap(), "Gemini");
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net"
);
} // #3
{
let item = link.next().unwrap();
assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string()));
let time = item.time(Some(&timezone)).unwrap();
assert_eq!(time.year(), 1965);
assert_eq!(time.month(), 1);
assert_eq!(time.day_of_month(), 19);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net"
);
} // #4
{
let item = link.next().unwrap();
assert_eq!(item.alt, Some("1965-01-19 Gemini".to_string()));
let time = item.time(Some(&timezone)).unwrap();
assert_eq!(time.year(), 1965);
assert_eq!(time.month(), 1);
assert_eq!(time.day_of_month(), 19);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net/docs/gemtext.gmi"
);
} // #5
{
let item = link.get(5).unwrap();
let item = link.next().unwrap();
assert_eq!(item.alt, None);
assert_eq!(item.timestamp, None);
assert_eq!(item.uri.to_str(), "gemini://geminiprotocol.net");
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net"
);
} // #6
{
let item = link.next().unwrap();
assert_eq!(item.alt, None);
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net"
);
} // #7
{
let item = link.next().unwrap();
assert_eq!(item.alt, None);
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net/path"
);
} // #8
{
let item = link.next().unwrap();
assert_eq!(item.alt, None);
assert_eq!(item.time(Some(&timezone)), None);
assert_eq!(
item.uri(Some(&base)).unwrap().to_string(),
"gemini://geminiprotocol.net/"
);
} // #9
// Validate lists
assert_eq!(list.len(), 2);
assert_eq!(list.get(0).unwrap().value, "Listing item 1");
assert_eq!(list.get(1).unwrap().value, "Listing item 2");
assert_eq!(list.first().unwrap().value, "Listing item 1");
assert_eq!(list.last().unwrap().value, "Listing item 2");
// Validate quotes
assert_eq!(quote.len(), 1);
assert_eq!(quote.get(0).unwrap().value, "quoted string");
assert_eq!(quote.first().unwrap().value, "quoted string");
}
// Could not load gemtext file
Err(_) => {
assert!(false);
}
Err(_) => panic!(),
}
}