wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

Commit: 337ba67f65eaa17b44e371af7c0f0c761d6aa914 (tree) main
Parent: 2b84405277e54ab809e328cf0237374d4b4dbd0c (tree)
Author: wayverd
Date: 2026 M02 23, Mon 21:59:04 -0500
12 files changed; 587 insertions 75 deletions
beginning on port over some of the changes from markdown-ppp since the fork

diff --git a/sable-markdown/Cargo.toml b/sable-markdown/Cargo.toml
index 276827e..220570a 100644
--- a/sable-markdown/Cargo.toml
+++ b/sable-markdown/Cargo.toml
@@ -3,6 +3,9 @@ name = "sable-markdown"
 version = "0.1.0"
 edition = "2024"
 
+authors = ["Evgenii Lepikhin <[email protected]>"]
+license = "MIT"
+
 workspace = ".."
 
 [features]
diff --git a/sable-markdown/README.md b/sable-markdown/README.md
index f8049df..e64bbf3 100644
--- a/sable-markdown/README.md
+++ b/sable-markdown/README.md
@@ -3,3 +3,5 @@
 a obsidian markdown parser and renderer.
 
 forked from [johnlepikhin/markdown-ppp](https://github.com/johnlepikhin/markdown-ppp).
+
+current upstream rev: 1e5cb4a248ee8a06d131d8a9b5823b6dcd280b07
diff --git a/sable-markdown/src/parser/inline/environment_variable.rs b/sable-markdown/src/parser/inline/environment_variable.rs
new file mode 100644
index 0000000..416d57f
--- /dev/null
+++ b/sable-markdown/src/parser/inline/environment_variable.rs
@@ -0,0 +1,48 @@
+use nom::{
+    IResult, Parser,
+    branch::alt,
+    character::complete::{alpha1, alphanumeric1, char},
+    combinator::{map, recognize, verify},
+    multi::many0,
+    sequence::pair,
+};
+
+use crate::ast::Inline;
+
+pub(super) fn environment_variable(input: &str) -> IResult<&str, Inline> {
+    map(
+        verify(
+            recognize(pair(
+                alpha1,
+                many0(alt((alphanumeric1, recognize(char('_'))))),
+            )),
+            |s: &str| is_likely_env_var(s),
+        ),
+        |var_name: &str| Inline::Text(var_name.to_string()),
+    )
+    .parse(input)
+}
+
+fn is_likely_env_var(s: &str) -> bool {
+    // Must contain at least one underscore
+    if !s.contains('_') {
+        return false;
+    }
+
+    // Must not start or end with underscore
+    if s.starts_with('_') || s.ends_with('_') {
+        return false;
+    }
+
+    // Must not have consecutive underscores
+    if s.contains("__") {
+        return false;
+    }
+
+    // Should be reasonable length (heuristic)
+    if s.len() < 3 || s.len() > 50 {
+        return false;
+    }
+
+    true
+}
diff --git a/sable-markdown/src/parser/inline/image.rs b/sable-markdown/src/parser/inline/image.rs
index 727bc45..e78653a 100644
--- a/sable-markdown/src/parser/inline/image.rs
+++ b/sable-markdown/src/parser/inline/image.rs
@@ -1,6 +1,6 @@
 use nom::{
     IResult, Parser,
-    bytes::complete::take_while1,
+    bytes::complete::take_while,
     character::complete::{char, multispace0},
     combinator::opt,
     sequence::{delimited, preceded},
@@ -16,7 +16,7 @@ pub(super) fn image<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
     move |input: &'a str| {
         let (input, alt) = preceded(
             char('!'),
-            delimited(char('['), take_while1(|c| c != ']'), char(']')),
+            delimited(char('['), take_while(|c| c != ']'), char(']')),
         )
         .parse(input)?;
 
diff --git a/sable-markdown/src/parser/inline/mod.rs b/sable-markdown/src/parser/inline/mod.rs
index 1a4b856..849efe8 100644
--- a/sable-markdown/src/parser/inline/mod.rs
+++ b/sable-markdown/src/parser/inline/mod.rs
@@ -1,6 +1,7 @@
 mod autolink;
 mod code_span;
 mod emphasis;
+mod environment_variable;
 mod footnote_reference;
 mod hard_newline;
 mod html_entity;
@@ -24,11 +25,45 @@ use nom::{
 
 use crate::ast::Inline;
 
+/// Merges consecutive Text elements into a single Text element
+fn merge_consecutive_text_elements(inlines: Vec<Inline>) -> Vec<Inline> {
+    let mut result = Vec::new();
+    let mut current_text = String::new();
+    let mut has_text = false;
+
+    for inline in inlines {
+        match inline {
+            Inline::Text(text) => {
+                current_text.push_str(&text);
+                has_text = true;
+            }
+            other => {
+                // If we have accumulated text, add it to result
+                if has_text {
+                    result.push(Inline::Text(current_text.clone()));
+                    current_text.clear();
+                    has_text = false;
+                }
+                // Add the non-text element
+                result.push(other);
+            }
+        }
+    }
+
+    // Don't forget the last accumulated text
+    if has_text {
+        result.push(Inline::Text(current_text));
+    }
+
+    result
+}
+
 pub(super) fn inline_many0<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
     move |input: &'a str| {
         let (input, list_of_lists) = many0(inline()).parse(input)?;
         let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
-        Ok((input, r))
+        let merged = merge_consecutive_text_elements(r);
+        Ok((input, merged))
     }
 }
 
@@ -36,7 +71,8 @@ pub(super) fn inline_many1<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<
     move |input: &'a str| {
         let (input, list_of_lists) = many1(inline()).parse(input)?;
         let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
-        Ok((input, r))
+        let merged = merge_consecutive_text_elements(r);
+        Ok((input, merged))
     }
 }
 
@@ -51,6 +87,7 @@ pub(super) fn inline<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline
             hard_newline::hard_newline,
             image::image(),
             map(code_span::code_span, Inline::Code),
+            environment_variable::environment_variable,
             emphasis::emphasis(),
             strikethrough::strikethrough(),
             map(tag::tag(), Inline::Tag),
diff --git a/sable-markdown/src/parser/inline/tests/consecutive_text_elements.rs b/sable-markdown/src/parser/inline/tests/consecutive_text_elements.rs
new file mode 100644
index 0000000..4575c04
--- /dev/null
+++ b/sable-markdown/src/parser/inline/tests/consecutive_text_elements.rs
@@ -0,0 +1,219 @@
+//! Tests for verifying that parser correctly merges consecutive text elements
+//!
+//! These tests check cases where the parser might create multiple consecutive
+//! Text elements that should be merged into a single element.
+
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+/// Checks that there are no consecutive Text elements in the vec
+fn assert_no_consecutive_text_elements(inlines: &[Inline]) {
+    for window in inlines.windows(2) {
+        if let [Inline::Text(_), Inline::Text(_)] = window {
+            panic!("Found consecutive Text elements in {inlines:?}, which should be merged");
+        }
+    }
+
+    // Recursively check content of other elements
+    for inline in inlines {
+        match inline {
+            Inline::Emphasis(content)
+            | Inline::Strong(content)
+            | Inline::Strikethrough(content) => {
+                assert_no_consecutive_text_elements(content);
+            }
+            Inline::Link(link) => {
+                assert_no_consecutive_text_elements(&link.children);
+            }
+            Inline::LinkReference(link_ref) => {
+                assert_no_consecutive_text_elements(&link_ref.label);
+                assert_no_consecutive_text_elements(&link_ref.text);
+            }
+            _ => {}
+        }
+    }
+}
+
+/// Checks entire document for absence of consecutive Text elements
+fn assert_no_consecutive_text_in_document(doc: &Document) {
+    for block in &doc.blocks {
+        match block {
+            Block::Paragraph(inlines) => {
+                assert_no_consecutive_text_elements(inlines);
+            }
+            Block::Heading(heading) => {
+                assert_no_consecutive_text_elements(&heading.content);
+            }
+            Block::BlockQuote(blocks) => {
+                assert_no_consecutive_text_in_document(&Document {
+                    blocks: blocks.clone(),
+                });
+            }
+            Block::List(list) => {
+                for item in &list.items {
+                    assert_no_consecutive_text_in_document(&Document {
+                        blocks: item.blocks.clone(),
+                    });
+                }
+            }
+            Block::Table(table) => {
+                for row in &table.rows {
+                    for cell in row {
+                        assert_no_consecutive_text_elements(cell);
+                    }
+                }
+            }
+            Block::FootnoteDefinition(footnote) => {
+                assert_no_consecutive_text_in_document(&Document {
+                    blocks: footnote.blocks.clone(),
+                });
+            }
+            Block::Callout(callout) => {
+                assert_no_consecutive_text_in_document(&Document {
+                    blocks: callout.blocks.clone(),
+                });
+            }
+            // Other blocks don't contain inline elements
+            _ => {}
+        }
+    }
+}
+
+#[test]
+fn test_environment_variables_with_text() {
+    // Environment variables between regular text
+    let doc =
+        parse_markdown("Set PKG_CONFIG_PATH and CMAKE_BUILD_TYPE to debug for testing").unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_emphasis_with_surrounding_text() {
+    // Emphasis with surrounding text
+    let doc = parse_markdown("This is *emphasized* text with more content").unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_multiple_environment_variables() {
+    // Multiple environment variables
+    let doc = parse_markdown("Use PATH_TO_FILE and CMAKE_BUILD_TYPE and PKG_CONFIG_PATH variables")
+        .unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_mixed_inline_elements() {
+    // Mix of different inline elements
+    let doc =
+        parse_markdown("Text with ENV_VAR and *emphasis* and **strong** and `code` formatting")
+            .unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_escaped_characters_with_text() {
+    // Escaped characters with text
+    let doc =
+        parse_markdown("Text with \\* escaped asterisk \\_ underscore and more text").unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_autolinks_with_text() {
+    // Autolinks with text
+    let doc =
+        parse_markdown("Visit <https://example.com> for more information about ENV_VAR usage")
+            .unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_html_entities_with_text() {
+    // HTML entities with text
+    let doc = parse_markdown("Text &amp; more text &lt; even more text").unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_complex_paragraph() {
+    // Complex paragraph with multiple elements
+    let doc = parse_markdown(
+        "Configure CMAKE_BUILD_TYPE to *debug* mode using PKG_CONFIG_PATH variable and check <https://example.com> for &amp; documentation"
+    ).unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_nested_emphasis_with_env_vars() {
+    // Nested emphasis with environment variables
+    let doc = parse_markdown("Use **strong with *nested ENV_VAR emphasis* and more** formatting")
+        .unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_list_items_with_mixed_content() {
+    // List items with mixed content
+    let doc = parse_markdown(
+        "- Set ENV_VAR to *value* for testing\n- Use CMAKE_BUILD_TYPE in **production** mode\n- Check &amp; validate settings"
+    ).unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_table_cells_with_mixed_content() {
+    // Table cells with mixed content
+    let doc = parse_markdown(
+        "| Variable | Value | Description |\n|----------|-------|-------------|\n| ENV_VAR | *debug* | Test &amp; development |\n| PKG_CONFIG_PATH | `/usr/lib` | System **path** |"
+    ).unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_blockquote_with_mixed_content() {
+    // Blockquote with mixed content
+    let doc = parse_markdown("> Set ENV_VAR for *testing* and check PKG_CONFIG_PATH configuration")
+        .unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_heading_with_mixed_content() {
+    // Heading with mixed content
+    let doc = parse_markdown("# Configuration of ENV_VAR and *Other* Settings").unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_multiline_paragraph_with_mixed_content() {
+    // Multiline paragraph with mixed content
+    let doc = parse_markdown(
+        "First line with ENV_VAR variable\nSecond line with *emphasis* formatting\nThird line with PKG_CONFIG_PATH and more content"
+    ).unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_footnote_reference_with_text() {
+    // Footnote references with text
+    let doc = parse_markdown(
+        "Text with footnote[^1] and ENV_VAR variable\n\n[^1]: Footnote with PKG_CONFIG_PATH reference"
+    ).unwrap();
+
+    assert_no_consecutive_text_in_document(&doc);
+}
diff --git a/sable-markdown/src/parser/inline/tests/emphasis.rs b/sable-markdown/src/parser/inline/tests/emphasis.rs
new file mode 100644
index 0000000..b7f4b3d
--- /dev/null
+++ b/sable-markdown/src/parser/inline/tests/emphasis.rs
@@ -0,0 +1,164 @@
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn emphasis1() {
+    let doc = parse_markdown("*foo bar*").unwrap();
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
+                Inline::Text("foo bar".to_string())
+            ])])],
+        }
+    );
+}
+
+#[test]
+fn emphasis2() {
+    let doc = parse_markdown("* a *").unwrap();
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::List(List {
+                kind: ListKind::Bullet(ListBulletKind::Star),
+                items: vec![ListItem {
+                    task: None,
+                    blocks: vec![Block::Paragraph(vec![Inline::Text("a *".to_owned())])]
+                }]
+            })]
+        }
+    );
+}
+
+#[test]
+fn emphasis3() {
+    let doc = parse_markdown("foo ___bar___").unwrap();
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![
+                Inline::Text("foo ".to_owned()),
+                Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])])
+            ])]
+        }
+    );
+}
+
+#[test]
+fn emphasis4() {
+    let doc = parse_markdown("**foo ___bar___ baz**").unwrap();
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Strong(vec![
+                Inline::Text("foo ".to_owned()),
+                Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])]),
+                Inline::Text(" baz".to_owned())
+            ])])]
+        }
+    );
+}
+
+#[test]
+fn emphasis_with_underscores_in_words() {
+    // Test case: PKG_CONFIG_PATH should not be parsed as PKG*CONFIG_PATH
+    let doc =
+        parse_markdown("Note that we set PKG_CONFIG_PATH only if it's not _already_ set").unwrap();
+
+    // Debug output
+    println!("Parsed document: {doc:?}");
+
+    // Expected: _already_ should be emphasized, PKG_CONFIG_PATH should remain as is
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![
+                Inline::Text("Note that we set PKG_CONFIG_PATH only if it's not ".to_string()),
+                Inline::Emphasis(vec![Inline::Text("already".to_string())]),
+                Inline::Text(" set".to_string())
+            ])],
+        }
+    );
+}
+
+#[test]
+fn test_simple_underscore() {
+    let doc = parse_markdown("_already_").unwrap();
+
+    println!("Simple underscore: {doc:?}");
+
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
+                Inline::Text("already".to_string())
+            ])])],
+        }
+    );
+}
+
+#[test]
+fn test_pkg_config() {
+    let doc = parse_markdown("PKG_CONFIG_PATH").unwrap();
+
+    println!("PKG_CONFIG_PATH: {doc:?}");
+
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Text(
+                "PKG_CONFIG_PATH".to_string()
+            )])],
+        }
+    );
+}
+
+#[test]
+fn test_multiple_env_vars_with_emphasis() {
+    let doc =
+        parse_markdown("Set PATH_TO_FILE and CMAKE_BUILD_TYPE to _debug_ for testing").unwrap();
+
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![
+                Inline::Text("Set PATH_TO_FILE and CMAKE_BUILD_TYPE to ".to_string()),
+                Inline::Emphasis(vec![Inline::Text("debug".to_string())]),
+                Inline::Text(" for testing".to_string())
+            ])],
+        }
+    );
+}
+
+#[test]
+fn test_env_var_mixed_case() {
+    let doc = parse_markdown("Use my_custom_var for configuration").unwrap();
+
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Text(
+                "Use my_custom_var for configuration".to_string()
+            )])],
+        }
+    );
+}
+
+#[test]
+fn test_false_positive_prevention() {
+    // These should NOT be parsed as environment variables
+    let doc = parse_markdown("Text with _emphasis_ and __strong__ formatting").unwrap();
+
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![
+                Inline::Text("Text with ".to_string()),
+                Inline::Emphasis(vec![Inline::Text("emphasis".to_string())]),
+                Inline::Text(" and ".to_string()),
+                Inline::Strong(vec![Inline::Text("strong".to_string())]),
+                Inline::Text(" formatting".to_string())
+            ])],
+        }
+    );
+}
diff --git a/sable-markdown/src/parser/inline/tests/emphasis.rs b/sable-markdown/src/parser/inline/tests/emphasis.rs
deleted file mode 100644
index 6252fd0..0000000
--- a/sable-markdown/src/parser/inline/tests/emphasis.rs
+++ /dev/null
@@ -1,60 +0,0 @@
-use crate::{ast::*, parser::parse_markdown};
-
-#[test]
-fn emphasis1() {
-    let doc = parse_markdown("*foo bar*").unwrap();
-    assert_eq!(
-        doc,
-        Document {
-            blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
-                Inline::Text("foo bar".to_string())
-            ])])],
-        }
-    );
-}
-
-#[test]
-fn emphasis2() {
-    let doc = parse_markdown("* a *").unwrap();
-    assert_eq!(
-        doc,
-        Document {
-            blocks: vec![Block::List(List {
-                kind: ListKind::Bullet(ListBulletKind::Star),
-                items: vec![ListItem {
-                    task: None,
-                    blocks: vec![Block::Paragraph(vec![Inline::Text("a *".to_owned())])]
-                }]
-            })]
-        }
-    );
-}
-
-#[test]
-fn emphasis3() {
-    let doc = parse_markdown("foo ___bar___").unwrap();
-    assert_eq!(
-        doc,
-        Document {
-            blocks: vec![Block::Paragraph(vec![
-                Inline::Text("foo ".to_owned()),
-                Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])])
-            ])]
-        }
-    );
-}
-
-#[test]
-fn emphasis4() {
-    let doc = parse_markdown("**foo ___bar___ baz**").unwrap();
-    assert_eq!(
-        doc,
-        Document {
-            blocks: vec![Block::Paragraph(vec![Inline::Strong(vec![
-                Inline::Text("foo ".to_owned()),
-                Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])]),
-                Inline::Text(" baz".to_owned())
-            ])])]
-        }
-    );
-}
diff --git a/sable-markdown/src/parser/inline/tests/environment_variable.rs b/sable-markdown/src/parser/inline/tests/environment_variable.rs
new file mode 100644
index 0000000..80cc9a0
--- /dev/null
+++ b/sable-markdown/src/parser/inline/tests/environment_variable.rs
@@ -0,0 +1,74 @@
+use super::super::environment_variable::*;
+use crate::ast::*;
+
+#[test]
+fn test_basic_env_var() {
+    let result = environment_variable("PKG_CONFIG_PATH").unwrap();
+    assert_eq!(result.0, "");
+    assert_eq!(result.1, Inline::Text("PKG_CONFIG_PATH".to_string()));
+}
+
+#[test]
+fn test_mixed_case_env_var() {
+    let result = environment_variable("CMAKE_Build_Type").unwrap();
+    assert_eq!(result.0, "");
+    assert_eq!(result.1, Inline::Text("CMAKE_Build_Type".to_string()));
+}
+
+#[test]
+fn test_lowercase_env_var() {
+    let result = environment_variable("my_custom_var").unwrap();
+    assert_eq!(result.0, "");
+    assert_eq!(result.1, Inline::Text("my_custom_var".to_string()));
+}
+
+#[test]
+fn test_env_var_with_numbers() {
+    let result = environment_variable("VAR_123_test").unwrap();
+    assert_eq!(result.0, "");
+    assert_eq!(result.1, Inline::Text("VAR_123_test".to_string()));
+}
+
+#[test]
+fn test_reject_no_underscore() {
+    assert!(environment_variable("NOUNDERCORE").is_err());
+}
+
+#[test]
+fn test_reject_starts_with_underscore() {
+    assert!(environment_variable("_INVALID").is_err());
+}
+
+#[test]
+fn test_reject_ends_with_underscore() {
+    assert!(environment_variable("INVALID_").is_err());
+}
+
+#[test]
+fn test_reject_consecutive_underscores() {
+    assert!(environment_variable("INVALID__VAR").is_err());
+}
+
+#[test]
+fn test_reject_too_short() {
+    assert!(environment_variable("A_").is_err());
+    assert!(environment_variable("_A").is_err());
+}
+
+#[test]
+fn test_integration_with_full_parser() {
+    use crate::parser::parse_markdown;
+
+    let result = parse_markdown("PKG_CONFIG_PATH").unwrap();
+    println!("Integration test result: {result:?}");
+
+    // Проверяем, что PKG_CONFIG_PATH парсится как один Text элемент
+    if let Block::Paragraph(inlines) = &result.blocks[0] {
+        assert_eq!(inlines.len(), 1);
+        if let Inline::Text(text) = &inlines[0] {
+            assert_eq!(text, "PKG_CONFIG_PATH");
+        } else {
+            panic!("Expected Text, got {:?}", inlines[0]);
+        }
+    }
+}
diff --git a/sable-markdown/src/parser/inline/tests/image.rs b/sable-markdown/src/parser/inline/tests/image.rs
index de95a85..fdcf4c9 100644
--- a/sable-markdown/src/parser/inline/tests/image.rs
+++ b/sable-markdown/src/parser/inline/tests/image.rs
@@ -44,3 +44,18 @@ fn image3() {
         }
     );
 }
+
+#[test]
+fn image4() {
+    let doc = parse_markdown("![](train.jpg)").unwrap();
+    assert_eq!(
+        doc,
+        Document {
+            blocks: vec![Block::Paragraph(vec![Inline::Image(Image {
+                destination: "train.jpg".to_owned(),
+                title: None,
+                alt: String::new(),
+            })])]
+        }
+    );
+}
diff --git a/sable-markdown/src/parser/inline/tests/mod.rs b/sable-markdown/src/parser/inline/tests/mod.rs
index d464e87..fc78225 100644
--- a/sable-markdown/src/parser/inline/tests/mod.rs
+++ b/sable-markdown/src/parser/inline/tests/mod.rs
@@ -1,6 +1,8 @@
 mod autolink;
 mod code_span;
+mod consecutive_text_elements;
 mod emphasis;
+mod environment_variable;
 mod footnote_reference;
 mod hard_newline;
 mod html_entity;
diff --git a/sable-markdown/src/parser/inline/text.rs b/sable-markdown/src/parser/inline/text.rs
index 27e3d7a..9a83f72 100644
--- a/sable-markdown/src/parser/inline/text.rs
+++ b/sable-markdown/src/parser/inline/text.rs
@@ -32,20 +32,28 @@ fn is_text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, ()> {
 fn not_a_text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<()>> {
     move |input: &'a str| {
         alt((
-            value((), crate::parser::inline::wikilink::wikilink()),
-            value((), crate::parser::inline::autolink::autolink),
-            value((), crate::parser::inline::reference_link::reference_link()),
-            value((), crate::parser::inline::hard_newline::hard_newline),
-            value((), crate::parser::inline::html_entity::html_entity()),
-            value((), crate::parser::inline::image::image()),
-            value((), crate::parser::inline::inline_link::inline_link()),
-            value((), crate::parser::inline::code_span::code_span),
-            value((), crate::parser::inline::emphasis::emphasis()),
+            alt((
+                value((), crate::parser::inline::wikilink::wikilink()),
+                value((), crate::parser::inline::autolink::autolink),
+                value((), crate::parser::inline::reference_link::reference_link()),
+                value((), crate::parser::inline::hard_newline::hard_newline),
+                value((), crate::parser::inline::html_entity::html_entity()),
+                value((), crate::parser::inline::image::image()),
+            )),
+            alt((
+                value((), crate::parser::inline::inline_link::inline_link()),
+                value((), crate::parser::inline::code_span::code_span),
+                value((), crate::parser::inline::emphasis::emphasis()),
+                value(
+                    (),
+                    crate::parser::inline::footnote_reference::footnote_reference,
+                ),
+                value((), crate::parser::inline::strikethrough::strikethrough()),
+            )),
             value(
                 (),
-                crate::parser::inline::footnote_reference::footnote_reference,
+                crate::parser::inline::environment_variable::environment_variable,
             ),
-            value((), crate::parser::inline::strikethrough::strikethrough()),
             value((), crate::parser::inline::tag::tag()),
         ))
         .map(|v| vec![v])