pub mod body_links;
pub mod config_schema;
pub mod facets;
pub mod findings;
pub mod links;
pub mod rules;
pub mod schema;
mod session;
pub mod workspace;
pub use body_links::{BodyLink, body_link_at, body_links};
pub use config_schema::{CONFIG_READONLY_KEYS, config_schema};
pub use facets::{Facet, Facets};
pub use findings::{Finding, Severity, Site};
pub use links::{AnyLink, MetaLink, TargetKind, link_at, links_in, links_under};
pub use schema::{Vocabularies, schema_for_document, schema_from_config};
pub use session::{DocumentSession, Heading, Region, SessionError, annotations_of};
pub use workspace::{Destination, WorkspaceView, reference_here, reference_without_workspace};
use std::collections::HashMap;
use fig::Value;
use flower_core::schema::FieldRuleExt;
use flower_core::tree::{self, to_fig};
use flower_core::{Backend, BackendError, Choice, EditOp, Schema, Seg};
use prov::edit::MetaEditor;
use prov::{Document, MetaCarrier};
fn be(e: impl std::fmt::Display) -> BackendError {
BackendError(e.to_string())
}
macro_rules! with_fig {
($editor:expr, |$e:ident| $body:expr) => {
match $editor {
MetaEditor::Fenced($e) => $body,
MetaEditor::Whole($e) => $body,
}
};
}
fn comment_read(read: Result<Option<String>, fig::Error>) -> Result<Option<String>, BackendError> {
match read {
Err(fig::Error::UnsupportedFormat) => Ok(None),
other => other.map_err(be),
}
}
pub struct ProvBackend {
path: std::path::PathBuf,
text: String,
schema: Option<Schema>,
candidates: HashMap<String, Vec<Choice>>,
}
impl ProvBackend {
pub fn open(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
) -> Result<Self, BackendError> {
Self::open_with_schema_opt(path, text, None)
}
pub fn open_with_schema(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
schema: Schema,
) -> Result<Self, BackendError> {
Self::open_with_schema_opt(path, text, Some(schema))
}
fn open_with_schema_opt(
path: impl Into<std::path::PathBuf>,
text: impl Into<String>,
schema: Option<Schema>,
) -> Result<Self, BackendError> {
let path = path.into();
let text = text.into();
Document::parse(&path, &text).map_err(be)?;
Ok(Self {
path,
text,
schema,
candidates: HashMap::new(),
})
}
fn document(&self) -> Result<Document, BackendError> {
Document::parse(&self.path, &self.text).map_err(be)
}
fn editor(&self) -> Result<Option<MetaEditor>, BackendError> {
match self.document()?.carrier {
Some(carrier) => MetaEditor::open(&self.text, carrier).map(Some).map_err(be),
None => Ok(None),
}
}
pub fn body(&self) -> Result<String, BackendError> {
Ok(self.document()?.body)
}
pub fn has_body(&self) -> Result<bool, BackendError> {
Ok(matches!(
self.document()?.carrier,
Some(MetaCarrier::Fenced(_))
))
}
pub fn set_candidates(&mut self, candidates: HashMap<String, Vec<Choice>>) {
self.candidates = candidates;
}
pub fn relation_at(&self, path: &[Seg]) -> Option<&str> {
self.schema.as_ref()?.rule_for(path)?.reference()
}
pub fn set_body(&mut self, body: &str) -> Result<(), BackendError> {
match self.document()?.carrier {
Some(MetaCarrier::Fenced(kind)) => {
let mut embed = fig::Embed::open(self.text.as_bytes(), kind).map_err(be)?;
embed.replace_body(body).map_err(be)?;
self.text = embed.render().map_err(be)?.to_string();
Ok(())
}
_ => Err(BackendError(
"document has no fenced body to replace".into(),
)),
}
}
}
impl Backend for ProvBackend {
fn apply(&mut self, op: EditOp) -> Result<(), BackendError> {
let carrier = self.document()?.carrier;
let mut editor = MetaEditor::open_or_init(&self.text, carrier).map_err(be)?;
match op {
EditOp::ReplaceValue { path, value } => {
let segs = to_fig(&path);
match path.last() {
Some(Seg::Index(_)) => editor.replace_value(&segs, value).map_err(be)?,
_ => editor.set_value(&segs, value).map_err(be)?,
}
}
EditOp::DeleteKey { path } => editor.delete(&to_fig(&path)).map_err(be)?,
EditOp::RemoveItem { seq_path, index } => {
editor.remove_item(&to_fig(&seq_path), index).map_err(be)?
}
EditOp::InsertKey {
map_path,
key,
value,
} => {
let mut path = map_path;
path.push(Seg::Key(key));
editor.set_value(&to_fig(&path), value).map_err(be)?
}
EditOp::AppendItem { seq_path, value } => {
editor.append_value(&to_fig(&seq_path), value).map_err(be)?
}
EditOp::MoveItem { seq_path, from, to } => {
let len = tree::seq_len(&self.to_value()?, &seq_path)
.ok_or_else(|| BackendError("target is not a sequence".into()))?;
if let Some(order) = flower_core::backend::move_permutation(len, from, to) {
editor
.reorder_items(&to_fig(&seq_path), &order)
.map_err(be)?;
}
}
EditOp::ReorderKeys { map_path, keys } => {
editor.reorder_keys(&to_fig(&map_path), &keys).map_err(be)?
}
EditOp::RenameKey { path, new_key } => {
editor.replace_key(&to_fig(&path), &new_key).map_err(be)?
}
EditOp::SetLeadingComment { path, text } => {
let path = to_fig(&path);
with_fig!(&mut editor, |e| {
e.delete_leading_comments(&path).map_err(be)?;
if let Some(text) = &text {
e.add_leading_comment(&path, text).map_err(be)?;
}
})
}
EditOp::SetTrailingComment { path, text } => {
let path = to_fig(&path);
with_fig!(&mut editor, |e| match &text {
Some(text) => e.set_trailing_comment(&path, text).map_err(be)?,
None => e.delete_trailing_comment(&path).map_err(be)?,
})
}
}
self.text = editor.render().map_err(be)?;
Ok(())
}
fn to_value(&self) -> Result<Value, BackendError> {
Ok(Value::from(&self.document()?.meta))
}
fn source(&self) -> Result<String, BackendError> {
Ok(self.text.clone())
}
fn schema(&self) -> Option<Schema> {
self.schema.clone()
}
fn leading_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
let Some(editor) = self.editor()? else {
return Ok(None);
};
let path = to_fig(path);
comment_read(with_fig!(&editor, |e| e.leading_comment(&path)))
}
fn trailing_comment(&self, path: &[Seg]) -> Result<Option<String>, BackendError> {
let Some(editor) = self.editor()? else {
return Ok(None);
};
let path = to_fig(path);
comment_read(with_fig!(&editor, |e| e.trailing_comment(&path)))
}
fn candidates(&self, path: &[Seg]) -> Result<Option<Vec<Choice>>, BackendError> {
Ok(self
.relation_at(path)
.and_then(|relation| self.candidates.get(relation))
.cloned())
}
fn item_key(&self, seq_path: &[Seg], index: usize) -> Result<Option<String>, BackendError> {
if self.relation_at(seq_path).is_none() {
return Ok(None);
}
let mut path = seq_path.to_vec();
path.push(Seg::Index(index));
let Some(Value::Str(text)) = tree::value_at(&self.to_value()?, &path).cloned() else {
return Ok(None);
};
let target = prov::Link::parse(&text).addressed_target().to_string();
Ok((!target.is_empty()).then_some(target))
}
}
#[cfg(test)]
mod tests {
use super::*;
use flower_core::{Mode, Model};
const DOC: &str = "\
---
# the title
title: Old Title
draft: true
tags:
- a
- b
---
# Heading
Body prose that must survive metadata edits.
";
fn model() -> Model<ProvBackend> {
let backend = ProvBackend::open("note.md", DOC).expect("open prov doc");
Model::new(backend).expect("build model")
}
fn select(model: &mut Model<ProvBackend>, path: &[Seg]) {
let index = model
.rows
.iter()
.position(|r| r.path == path)
.unwrap_or_else(|| panic!("no row for {path:?}"));
model.select_row(index);
}
fn type_value(model: &mut Model<ProvBackend>, text: &str) {
if let Mode::Editing { buffer, .. } = &mut model.mode {
buffer.clear();
}
for c in text.chars() {
model.edit_push(c);
}
model.edit_commit();
}
#[test]
fn prov_backend_satisfies_the_edit_op_contract() {
const FIXTURE: &str = "\
---
title: note
tags:
- alpha
- beta
- gamma
nested:
k: v
j: w
---
# Note
Body prose.
";
flower_core::backend::conformance::check(|| {
ProvBackend::open("note.md", FIXTURE).expect("open fixture")
})
.expect("prov backend honors the EditOp contract");
}
#[test]
fn renders_frontmatter_as_a_tree() {
let model = model();
let keys: Vec<&str> = model
.rows
.iter()
.filter(|r| r.depth == 0)
.map(|r| r.label.as_str())
.collect();
assert_eq!(
keys,
["title", "draft", "tags"],
"top-level frontmatter keys"
);
}
#[test]
fn edits_metadata_leaving_the_body_untouched() {
let mut model = model();
select(&mut model, &[Seg::Key("title".into())]);
model.begin_edit();
type_value(&mut model, "New Title");
let out = model.source_snapshot();
assert!(out.contains("title: New Title"), "value changed:\n{out}");
assert!(out.contains("# the title"), "comment preserved:\n{out}");
assert!(out.starts_with("---\n"), "fences intact:\n{out}");
assert!(
out.contains("Body prose that must survive metadata edits."),
"body preserved:\n{out}"
);
}
#[test]
fn deletes_a_key() {
let mut model = model();
select(&mut model, &[Seg::Key("draft".into())]);
model.delete_selected();
let out = model.source_snapshot();
assert!(!out.contains("draft:"), "key removed:\n{out}");
assert!(out.contains("title: Old Title"), "siblings kept:\n{out}");
assert!(out.contains("Body prose"), "body kept:\n{out}");
}
#[test]
fn comments_are_read_per_node_and_edited_in_place_leaving_the_body_alone() {
let backend = ProvBackend::open("note.md", DOC).expect("open");
let mut model = Model::new(backend).expect("model");
let title = [Seg::Key("title".into())];
let draft = [Seg::Key("draft".into())];
assert_eq!(
model.leading_comment_at(&title).as_deref(),
Some("the title")
);
assert_eq!(model.leading_comment_at(&draft), None);
assert_eq!(model.trailing_comment_at(&title), None);
model.set_leading_comment(&title, Some("what it is called"));
model.set_trailing_comment(&draft, Some("for now"));
let out = model.source_snapshot();
assert!(
out.contains("# what it is called\ntitle: Old Title"),
"{out}"
);
assert!(
!out.contains("# the title"),
"the block is replaced:\n{out}"
);
assert!(out.contains("draft: true # for now"), "{out}");
assert!(
out.contains("Body prose that must survive"),
"body kept:\n{out}"
);
model.set_leading_comment(&title, None);
let out = model.source_snapshot();
assert!(out.starts_with("---\ntitle: Old Title"), "{out}");
}
#[test]
fn a_comment_write_that_fig_refuses_leaves_the_document_as_it_was() {
let mut backend = ProvBackend::open("note.md", DOC).expect("open");
let before = backend.source().unwrap();
let result = backend.apply(EditOp::SetTrailingComment {
path: vec![Seg::Key("title".into())],
text: Some("two\nlines".into()),
});
assert!(result.is_err());
assert_eq!(backend.source().unwrap(), before);
}
#[test]
fn json_frontmatter_has_no_comments_to_read_and_refuses_to_write_one() {
let doc = ";;;\n{\"title\": \"Note\"}\n;;;\n# Note\n";
let mut backend = ProvBackend::open("note.md", doc).expect("open");
let title = vec![Seg::Key("title".into())];
assert_eq!(backend.leading_comment(&title).unwrap(), None);
assert_eq!(backend.trailing_comment(&title).unwrap(), None);
let before = backend.source().unwrap();
assert!(
backend
.apply(EditOp::SetLeadingComment {
path: title,
text: Some("nope".into()),
})
.is_err()
);
assert_eq!(backend.source().unwrap(), before);
}
#[test]
fn a_document_with_no_metadata_block_has_no_comments() {
let backend = ProvBackend::open("note.md", "# Just prose\n").expect("open");
assert_eq!(
backend
.leading_comment(&[Seg::Key("title".into())])
.unwrap(),
None
);
}
#[test]
fn a_relations_item_is_identified_by_its_target_not_its_position_or_label() {
const INDEX: &str = "\
---
title: Index
contents:
- '[One](one.md)'
- '[Two](two.md)'
- '[Three](three.md)'
tags:
- alpha
- beta
---
# Index
";
let schema = schema_from_config(
&prov::config::WorkspaceConfig::default(),
&std::collections::BTreeMap::new(),
);
let backend = ProvBackend::open_with_schema("index.md", INDEX, schema).expect("open");
let mut model = Model::new(backend).expect("model");
let contents = [Seg::Key("contents".into())];
assert_eq!(model.item_key(&contents, 0).as_deref(), Some("one.md"));
assert_eq!(model.item_key(&contents, 1).as_deref(), Some("two.md"));
assert_eq!(model.item_key(&contents, 2).as_deref(), Some("three.md"));
let tags = [Seg::Key("tags".into())];
assert_eq!(model.item_key(&tags, 0).as_deref(), Some("alpha"));
let second = [Seg::Key("contents".into()), Seg::Index(1)];
model.focus_on(&second);
assert_eq!(
model.page_item().map(|i| i.path.clone()),
Some(second.to_vec()),
"the cursor is on the item that was opened on"
);
model.move_selected_up();
let landed = model.page_item().expect("still on an item").path.clone();
assert_eq!(landed, [Seg::Key("contents".into()), Seg::Index(0)]);
assert_eq!(
model.item_key(&contents, 0).as_deref(),
Some("two.md"),
"the item moved, and the cursor moved with it"
);
assert_eq!(model.item_key(&contents, 1).as_deref(), Some("one.md"));
model.set_value_at(&landed, Value::Str("[The Second One](two.md)".into()));
assert_eq!(model.item_key(&contents, 0).as_deref(), Some("two.md"));
assert_eq!(
model.page_item().map(|i| i.path.clone()),
Some(landed),
"the cursor did not go looking for a document that never moved"
);
model.set_value_at(
&[Seg::Key("contents".into()), Seg::Index(1)],
Value::Str("[One](one.md#a-heading)".into()),
);
assert_eq!(model.item_key(&contents, 1).as_deref(), Some("one.md"));
}
#[test]
fn schema_backed_backend_rejects_a_term_outside_a_closed_vocabulary() {
use flower_core::schema::{Constraint, FieldRule};
use flower_core::{FieldType, PathPat, Term};
let doc = "---\ntitle: Note\naudience:\n- public\n---\n# Note\n";
let schema = Schema::new(vec![
FieldRule::new(PathPat::each_item_of("audience"))
.ty(FieldType::Str)
.constraint(Constraint::Enum {
values: vec![Term::value("public"), Term::value("private")],
closed: true,
}),
]);
let backend = ProvBackend::open_with_schema("note.md", doc, schema).expect("open");
let mut model = Model::new(backend).expect("model");
select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
model.begin_edit();
type_value(&mut model, "familly");
assert!(
model.status.contains("rejected"),
"status: {}",
model.status
);
assert!(model.source_snapshot().contains("- public"), "unchanged");
model.begin_edit();
type_value(&mut model, "private");
assert!(model.source_snapshot().contains("- private"), "applied");
}
}