use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedMemory {
pub name: String,
pub kind: String,
pub text: String,
pub tags: Vec<String>,
}
pub fn parse_memory_file(source: &str) -> anyhow::Result<Option<ParsedMemory>> {
let Some((frontmatter, body)) = split_frontmatter(source) else {
return Ok(None);
};
let fields = scan_frontmatter(frontmatter)?;
if fields.name.is_empty() {
anyhow::bail!("frontmatter has no `name` field");
}
let mut text = describe(&fields.description);
text.push_str("\n\n");
text.push_str(body.trim());
let mut tags: BTreeSet<String> = BTreeSet::new();
tags.insert(fields.name.clone());
if !fields.kind.is_empty() {
tags.insert(fields.kind.clone());
}
tags.extend(wikilink_targets(body));
Ok(Some(ParsedMemory {
name: fields.name,
kind: fields.kind,
text,
tags: tags.into_iter().collect(),
}))
}
pub(super) fn describe(description: &str) -> String {
let trimmed = description.trim();
match trimmed.chars().last() {
None => String::new(),
Some('.') | Some(':') | Some('"') => trimmed.to_string(),
Some(_) => format!("{trimmed}."),
}
}
pub(super) fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
let stripped = source.strip_prefix('\u{feff}').unwrap_or(source);
let rest = stripped
.strip_prefix("---\n")
.or_else(|| stripped.strip_prefix("---\r\n"))?;
let mut offset = 0usize;
for line in rest.split_inclusive('\n') {
if line.trim_end() == "---" {
return Some((&rest[..offset], &rest[offset + line.len()..]));
}
offset += line.len();
}
None
}
#[derive(Default)]
struct Fields {
name: String,
description: String,
kind: String,
}
fn scan_frontmatter(frontmatter: &str) -> anyhow::Result<Fields> {
let lines: Vec<&str> = frontmatter.lines().collect();
let mut out = Fields::default();
let mut path: Vec<(usize, String)> = Vec::new();
let mut plain: Option<(usize, String)> = None;
let mut i = 0usize;
while i < lines.len() {
let raw = lines[i];
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
i += 1;
continue;
}
let indent = raw.len() - raw.trim_start().len();
if let Some((plain_indent, plain_key)) = &plain
&& indent > *plain_indent
{
anyhow::bail!(
"`{plain_key}` is a plain multi-line scalar (continuation {trimmed:?}); \
rewrite it as a single line or a `>`/`|` block scalar"
);
}
let Some((key, head)) = trimmed.split_once(':') else {
anyhow::bail!("frontmatter line has no `key:` separator: {trimmed:?}");
};
let key = key.trim().to_string();
let head = head.trim();
let multiline_risk = !head.is_empty() && block_style(head).is_none();
let (value, next) = match block_style(head) {
Some(style) => read_block_scalar(&lines, i + 1, indent, style),
None => (head.to_string(), i + 1),
};
while path.last().is_some_and(|(depth, _)| *depth >= indent) {
path.pop();
}
match key.as_str() {
"name" if indent == 0 => out.name = value,
"description" if indent == 0 => out.description = value,
"type" if path.len() == 1 && path[0].1 == "metadata" => out.kind = value,
_ => {}
}
plain = multiline_risk.then(|| (indent, key.clone()));
path.push((indent, key));
i = next;
}
Ok(out)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockStyle {
Folded,
Literal,
}
fn block_style(value: &str) -> Option<BlockStyle> {
let mut chars = value.chars();
let style = match chars.next()? {
'>' => BlockStyle::Folded,
'|' => BlockStyle::Literal,
_ => return None,
};
if chars.all(|c| matches!(c, '-' | '+') || c.is_ascii_digit()) {
Some(style)
} else {
None
}
}
fn read_block_scalar(
lines: &[&str],
start: usize,
parent_indent: usize,
style: BlockStyle,
) -> (String, usize) {
let mut collected: Vec<&str> = Vec::new();
let mut i = start;
while i < lines.len() {
let line = lines[i];
if line.trim().is_empty() {
collected.push("");
i += 1;
continue;
}
let indent = line.len() - line.trim_start().len();
if indent <= parent_indent {
break;
}
collected.push(line);
i += 1;
}
let block_indent = collected
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
let dedented: Vec<&str> = collected.iter().map(|l| dedent(l, block_indent)).collect();
let value = match style {
BlockStyle::Literal => dedented.join("\n"),
BlockStyle::Folded => {
let mut out = String::new();
for line in &dedented {
if line.trim().is_empty() {
out.push('\n');
} else {
if !out.is_empty() && !out.ends_with('\n') {
out.push(' ');
}
out.push_str(line.trim_end());
}
}
out
}
};
(value.trim().to_string(), i)
}
fn dedent(line: &str, n: usize) -> &str {
line.get(n..).unwrap_or_else(|| line.trim_start())
}
pub(super) fn wikilink_targets(body: &str) -> Vec<String> {
let bytes = body.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i + 1 < bytes.len() {
if bytes[i] != b'[' || bytes[i + 1] != b'[' {
i += 1;
continue;
}
let inner_start = i + 2;
let Some(rel_end) = body[inner_start..].find("]]") else {
break;
};
let inner = &body[inner_start..inner_start + rel_end];
if let Some(target) = normalise_link_target(inner) {
out.push(target);
}
i = inner_start + rel_end + 2;
}
out
}
fn normalise_link_target(inner: &str) -> Option<String> {
if inner.contains('\n') {
return None;
}
let target = inner
.split('|')
.next()
.unwrap_or(inner)
.split('#')
.next()
.unwrap_or(inner)
.trim();
let target = target.strip_suffix(".md").unwrap_or(target).trim();
(!target.is_empty()).then(|| target.to_string())
}