use std::borrow::Cow;
use super::node::{Block, Inline};
use super::parser::ParseConfig;
use super::url::Url;
use crate::inert_regions::mask_inert;
const OPEN: char = '\u{e000}';
const CLOSE: char = '\u{e001}';
#[derive(Debug, Default)]
pub(super) struct LinkedEmbeds {
sources: Vec<String>,
nonce: String,
}
pub(super) fn substitute<'a>(markdown: &'a str, nonce: &str) -> (Cow<'a, str>, LinkedEmbeds) {
let mut embeds = LinkedEmbeds::default();
if !markdown.contains("[![[") {
return (Cow::Borrowed(markdown), embeds);
}
let mask = mask_inert(markdown);
let bytes = mask.as_bytes();
let mut out = String::with_capacity(markdown.len());
let mut copied = 0usize;
let mut i = 0usize;
while i + 4 <= bytes.len() {
if !bytes[i..].starts_with(b"[![[") {
i += 1;
continue;
}
if i > 0 && (bytes[i - 1] == b'!' || bytes[i - 1] == b'\\') {
i += 1;
continue;
}
let Some((embed_end, target_len)) = embed_span(bytes, i + 1) else {
i += 1;
continue;
};
if target_len == 0 || !link_closes_at(bytes, embed_end) {
i += 1;
continue;
}
let embed_start = i + 1;
#[allow(clippy::string_slice)]
{
out.push_str(&markdown[copied..embed_start]);
out.push_str(&sentinel_for(nonce, embeds.sources.len()));
embeds
.sources
.push(markdown[embed_start..embed_end].to_string());
}
copied = embed_end;
i = embed_end;
}
if embeds.sources.is_empty() {
return (Cow::Borrowed(markdown), embeds);
}
embeds.nonce = nonce.to_string();
#[allow(clippy::string_slice)]
out.push_str(&markdown[copied..]);
(Cow::Owned(out), embeds)
}
fn sentinel_for(nonce: &str, index: usize) -> String {
format!("{OPEN}{nonce}:{index}{CLOSE}")
}
fn embed_span(bytes: &[u8], start: usize) -> Option<(usize, usize)> {
let target_start = start + 3;
let mut j = target_start;
while j + 1 < bytes.len() {
match bytes[j] {
b'\n' | b'[' => return None,
b']' if bytes[j + 1] == b']' => return Some((j + 2, j - target_start)),
b']' => return None,
_ => j += 1,
}
}
None
}
fn link_closes_at(bytes: &[u8], at: usize) -> bool {
if bytes.get(at) != Some(&b']') || bytes.get(at + 1) != Some(&b'(') {
return false;
}
let mut depth = 1usize;
let mut j = at + 2;
while j < bytes.len() {
match bytes[j] {
b'\\' => j += 1,
b'\n' => return false,
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return true;
}
}
_ => {}
}
j += 1;
}
false
}
pub(super) fn restore(blocks: &mut [Block], embeds: &LinkedEmbeds, config: &ParseConfig) {
if embeds.sources.is_empty() {
return;
}
let ctx = Restore {
embeds,
config: ParseConfig {
emit_source_lines: false,
implicit_figure: false,
source_line_offset: 0,
math: config.math,
hard_line_breaks: false,
},
};
for block in blocks.iter_mut() {
ctx.in_block(block);
}
}
struct Restore<'a> {
embeds: &'a LinkedEmbeds,
config: ParseConfig,
}
impl Restore<'_> {
fn in_block(&self, block: &mut Block) {
match block {
Block::Heading { children, id, level: _ } => {
if self.in_inlines(children) {
let text = crate::ast::plain_text::inlines_to_plain_text(children);
*id = Some(crate::heading::anchor::obsidian_heading_anchor(&text));
}
}
Block::Paragraph(children) => {
self.in_inlines(children);
}
Block::Figure {
image,
caption,
width: _,
align: _,
class_names: _,
img_style: _,
} => {
self.in_inline(image);
if let Some(caption) = caption {
self.in_inlines(caption);
}
}
Block::LinkCard { url, children } => {
self.literal_url(url);
for nested in children {
self.in_block(nested);
}
}
Block::Callout {
title,
children,
kind: _,
fold: _,
} => {
self.literal_opt(title);
for nested in children {
self.in_block(nested);
}
}
Block::BlockQuote(children) => {
for nested in children {
self.in_block(nested);
}
}
Block::FootnoteDefinition { label, children } => {
self.literal(label);
for nested in children {
self.in_block(nested);
}
}
Block::List {
items,
ordered: _,
start: _,
item_source_lines: _,
} => {
for item in items {
for nested in item {
self.in_block(nested);
}
}
}
Block::Table {
header,
rows,
alignments: _,
header_source_line: _,
row_source_lines: _,
} => {
for cell in header.iter_mut().chain(rows.iter_mut().flatten()) {
self.in_inlines(cell);
}
}
Block::CodeBlock { value, lang } => {
self.literal(value);
self.literal_opt(lang);
}
Block::Other(html) => {
self.literal(html);
}
Block::Shortcode(_) | Block::ThematicBreak => {}
}
}
fn in_inlines(&self, inlines: &mut Vec<Inline>) -> bool {
let has_sentinel = inlines
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains(OPEN)));
if !has_sentinel {
let mut changed = false;
for inline in inlines.iter_mut() {
changed |= self.in_inline(inline);
}
return changed;
}
let mut out: Vec<Inline> = Vec::with_capacity(inlines.len());
for mut inline in std::mem::take(inlines) {
if let Inline::Text(text) = &inline {
if text.contains(OPEN) {
out.extend(self.expand(text));
continue;
}
}
self.in_inline(&mut inline);
out.push(inline);
}
*inlines = out;
true
}
fn in_inline(&self, inline: &mut Inline) -> bool {
match inline {
Inline::Emphasis(children)
| Inline::Strong(children)
| Inline::Strikethrough(children) => self.in_inlines(children),
Inline::Link {
children,
url,
title,
is_wikilink: _,
} => {
let mut changed = self.in_inlines(children);
changed |= self.literal_url(url);
changed |= self.literal_opt(title);
changed
}
Inline::Image {
src,
alt,
title,
wikilink_pothole,
is_wikilink: _,
} => {
let mut changed = self.literal_url(src);
changed |= self.literal(alt);
changed |= self.literal_opt(title);
changed |= self.literal_opt(wikilink_pothole);
changed
}
Inline::Code(code) => self.literal(code),
Inline::Other(raw) => self.literal(raw),
Inline::FootnoteRef(label) => self.literal(label),
Inline::Text(_) | Inline::LineBreak | Inline::TaskMarker(_) => false,
}
}
fn expand(&self, text: &str) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
for segment in self.segments(text) {
match segment {
Segment::Text("") => {}
Segment::Text(s) => out.push(Inline::Text(s.to_string())),
Segment::Embed(source) => out.extend(self.embed_inlines(source)),
}
}
out
}
#[allow(clippy::string_slice)]
fn segments<'t>(&'t self, text: &'t str) -> Vec<Segment<'t>> {
let mut out: Vec<Segment<'t>> = Vec::new();
let mut rest = text;
let mut pending = 0usize;
while let Some(open) = rest[pending..].find(OPEN) {
let open = pending + open;
let after = &rest[open + OPEN.len_utf8()..];
let source = after
.find(CLOSE)
.and_then(|close| {
let body = &after[..close];
let id = body.strip_prefix(&self.embeds.nonce)?.strip_prefix(':')?;
id.parse::<usize>().ok().map(|id| (close, id))
})
.and_then(|(close, id)| self.embeds.sources.get(id).map(|s| (close, s)));
match source {
Some((close, source)) => {
out.push(Segment::Text(&rest[..open]));
out.push(Segment::Embed(source));
rest = &after[close + CLOSE.len_utf8()..];
pending = 0;
}
None => pending = open + OPEN.len_utf8(),
}
}
out.push(Segment::Text(rest));
out
}
fn embed_inlines(&self, source: &str) -> Vec<Inline> {
let doc = super::parser::parse_with_config(source, &self.config);
match doc.blocks.into_iter().next() {
Some(Block::Paragraph(inlines))
if matches!(inlines.as_slice(), [Inline::Image { .. }]) =>
{
inlines
}
_ => vec![Inline::Text(source.to_string())],
}
}
fn literal(&self, text: &mut String) -> bool {
if !text.contains(OPEN) {
return false;
}
let source = std::mem::take(text);
let mut out = String::with_capacity(source.len());
for segment in self.segments(&source) {
match segment {
Segment::Text(s) | Segment::Embed(s) => out.push_str(s),
}
}
*text = out;
true
}
fn literal_opt(&self, text: &mut Option<String>) -> bool {
text.as_mut().is_some_and(|t| self.literal(t))
}
fn literal_url(&self, url: &mut Url) -> bool {
match url {
Url::Unresolved(raw) => self.literal(raw),
Url::Resolved(resolved) => self.literal(&mut resolved.href),
}
}
}
enum Segment<'a> {
Text(&'a str),
Embed(&'a str),
}
#[cfg(test)]
#[path = "linked_embed_tests.rs"]
mod tests;