Skip to main content

bekoedit_markdown/
form.rs

1//! Form Mode projection and semantic edit commands
2//! (RFC-016 surface, RFC-017 islands, RFC-018 command set).
3//!
4//! The UI sends `FormEditCommand` values targeting revision-scoped block
5//! identity (RFC-014). This module resolves them into minimal,
6//! style-preserving `SourcePatch` values; it never rewrites unrelated
7//! regions and never trusts client-supplied byte ranges.
8
9mod images;
10mod inline_fmt;
11mod resolve;
12mod tables;
13
14use serde::{Deserialize, Serialize};
15
16use crate::block::{BlockKind, BlockNode, EditablePolicy};
17use crate::fingerprint::{BlockFingerprint, BlockId};
18use crate::index::MarkdownIndex;
19use crate::island::RawIslandType;
20
21pub use inline_fmt::resolve_toggle_inline;
22pub use resolve::resolve_form_edit;
23
24/// One visual block in the Form Mode projection (RFC-016 §7).
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct FormBlock {
27    pub block_id: BlockId,
28    pub kind: BlockKind,
29    pub editable_policy: EditablePolicy,
30    pub display: FormBlockDisplay,
31}
32
33/// Render-ready content for each supported block type.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum FormBlockDisplay {
36    Heading {
37        level: u8,
38        text: String,
39        /// `false` for setext headings, whose level cannot be changed safely.
40        level_editable: bool,
41    },
42    Paragraph {
43        text: String,
44    },
45    List {
46        ordered: bool,
47        items: Vec<FormListItem>,
48    },
49    Blockquote {
50        text: String,
51    },
52    Code {
53        language: Option<String>,
54        code: String,
55    },
56    HorizontalRule,
57    /// Image block (RFC-028): preview card with editable alt text and path.
58    Image {
59        alt: String,
60        src: String,
61    },
62    /// Simple GFM table: all cells are plain text (RFC-027).
63    Table {
64        headers: Vec<String>,
65        rows: Vec<Vec<String>>,
66        /// Number of data columns (mirrors `headers.len()`).
67        col_count: usize,
68    },
69    RawIsland {
70        island_type: RawIslandType,
71        /// Translated by the GUI via i18n.
72        label_key: String,
73        text: String,
74        editable: bool,
75    },
76}
77
78/// One item inside a list `FormBlock`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FormListItem {
81    pub ordinal: u32,
82    pub text: String,
83    pub task_checked: Option<bool>,
84}
85
86/// The Form Mode projection (external design §23.10). Disposable;
87/// rebuilt from the `MarkdownIndex` after every accepted mutation.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct FormProjection {
90    pub document_revision: u64,
91    pub blocks: Vec<FormBlock>,
92}
93
94impl FormProjection {
95    /// Builds the projection for `index` over canonical `text`.
96    pub fn build(text: &str, index: &MarkdownIndex) -> Self {
97        let blocks = index
98            .blocks
99            .iter()
100            .map(|b| FormBlock {
101                block_id: b.block_id,
102                kind: b.kind,
103                editable_policy: b.editable_policy,
104                display: display_for(text, index, b),
105            })
106            .collect();
107        Self {
108            document_revision: index.document_revision,
109            blocks,
110        }
111    }
112}
113
114fn display_for(text: &str, index: &MarkdownIndex, block: &BlockNode) -> FormBlockDisplay {
115    if block.editable_policy == EditablePolicy::RawIslandOnly {
116        let island = index
117            .raw_islands
118            .iter()
119            .find(|i| i.block_id == block.block_id);
120        let island_type = island
121            .map(|i| i.island_type)
122            .unwrap_or(RawIslandType::UnknownExtension);
123        return FormBlockDisplay::RawIsland {
124            island_type,
125            label_key: island_type.label_key().to_string(),
126            text: slice(text, block.source_range.start, block.source_range.end),
127            editable: true,
128        };
129    }
130    let content = |r: Option<crate::range::ByteRange>| {
131        r.map(|r| slice(text, r.start, r.end)).unwrap_or_default()
132    };
133    match block.kind {
134        BlockKind::Heading => {
135            let first = slice(text, block.source_range.start, block.source_range.end);
136            let level_editable = first.trim_start().starts_with('#');
137            FormBlockDisplay::Heading {
138                level: block.heading_level.unwrap_or(1),
139                text: content(block.content_range),
140                level_editable,
141            }
142        }
143        BlockKind::Paragraph => FormBlockDisplay::Paragraph {
144            text: content(block.content_range),
145        },
146        BlockKind::Blockquote => FormBlockDisplay::Blockquote {
147            text: content(block.content_range),
148        },
149        BlockKind::FencedCode => FormBlockDisplay::Code {
150            language: block.code_language.clone(),
151            code: content(block.content_range),
152        },
153        BlockKind::HorizontalRule => FormBlockDisplay::HorizontalRule,
154        BlockKind::BulletList | BlockKind::OrderedList | BlockKind::TaskList => {
155            FormBlockDisplay::List {
156                ordered: block.kind == BlockKind::OrderedList,
157                items: block
158                    .items
159                    .iter()
160                    .map(|it| FormListItem {
161                        ordinal: it.ordinal,
162                        text: slice(text, it.content_range.start, it.content_range.end),
163                        task_checked: it.task_checked,
164                    })
165                    .collect(),
166            }
167        }
168        BlockKind::SimpleTable => {
169            let source = slice(text, block.source_range.start, block.source_range.end);
170            // Parse the table into a display-friendly structure.
171            let (headers, rows) = parse_simple_table(&source);
172            let col_count = headers.len();
173            FormBlockDisplay::Table {
174                headers,
175                rows,
176                col_count,
177            }
178        }
179        BlockKind::HtmlBlock => FormBlockDisplay::RawIsland {
180            island_type: RawIslandType::HtmlBlock,
181            label_key: RawIslandType::HtmlBlock.label_key().to_string(),
182            text: slice(text, block.source_range.start, block.source_range.end),
183            editable: true,
184        },
185        _ => FormBlockDisplay::RawIsland {
186            island_type: RawIslandType::UnknownExtension,
187            label_key: RawIslandType::UnknownExtension.label_key().to_string(),
188            text: slice(text, block.source_range.start, block.source_range.end),
189            editable: true,
190        },
191    }
192}
193
194fn slice(text: &str, start: usize, end: usize) -> String {
195    text.get(start..end).unwrap_or_default().to_string()
196}
197
198/// The kind of inline formatting toggle (RFC-030).
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200pub enum InlineFormat {
201    Bold,
202    Italic,
203    Code,
204    Link,
205}
206
207impl InlineFormat {
208    /// The Markdown marker string surrounding the selected text.
209    pub fn open_marker(self) -> &'static str {
210        match self {
211            InlineFormat::Bold => "**",
212            InlineFormat::Italic => "_",
213            InlineFormat::Code => "`",
214            InlineFormat::Link => "[",
215        }
216    }
217    pub fn close_marker(self) -> &'static str {
218        match self {
219            InlineFormat::Bold => "**",
220            InlineFormat::Italic => "_",
221            InlineFormat::Code => "`",
222            InlineFormat::Link => "]", // caller appends (url)
223        }
224    }
225}
226
227/// Semantic edits a Form Mode block may request (RFC-018 §7, as amended
228/// by the 2026-06-07 review to include `ReplaceListItemText` and
229/// `DeleteBlock` per external design §23.11).
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub enum FormBlockEdit {
232    ReplacePlainText {
233        text: String,
234    },
235    SetHeadingLevel {
236        level: u8,
237    },
238    ToggleTaskChecked {
239        item_ordinal: u32,
240        checked: bool,
241    },
242    ReplaceListItemText {
243        item_ordinal: u32,
244        text: String,
245    },
246    ReplaceCodeBlock {
247        language: Option<String>,
248        code: String,
249    },
250    ReplaceRawIsland {
251        text: String,
252    },
253    DeleteBlock,
254    /// Replace alt text and/or path for an image block (RFC-028).
255    ReplaceImage {
256        alt: String,
257        src: String,
258    },
259    /// Edit a single cell in a simple GFM table (RFC-027).
260    ReplaceTableCell {
261        row: usize,
262        col: usize,
263        text: String,
264    },
265    /// Append a new empty row to a simple table (RFC-027).
266    AddTableRow,
267    /// Toggle inline markup around a JS-editor selection (RFC-030).
268    /// Offsets are UTF-16 code units relative to the block's content range
269    /// start; Rust converts them to UTF-8 before patching.
270    ToggleInline {
271        kind: InlineFormat,
272        utf16_start: usize,
273        utf16_len: usize,
274        /// URL to use when `kind == Link`.
275        link_url: Option<String>,
276    },
277}
278
279/// A semantic edit command from the UI (RFC-018 §7). Carries no
280/// authoritative byte ranges by design.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct FormEditCommand {
283    pub base_revision: u64,
284    pub block_id: BlockId,
285    /// Optional client-side fingerprint for extra validation/diagnostics.
286    pub client_block_fingerprint: Option<BlockFingerprint>,
287    pub edit: FormBlockEdit,
288}
289
290/// Structured rejection reasons for Form Mode commands
291/// (requirements §23.3, RFC-014 stale-command handling).
292#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
293pub enum FormEditError {
294    #[error("document revision mismatch: command base {base}, current {current}")]
295    DocumentRevisionMismatch { base: u64, current: u64 },
296    #[error("block not found for the given id")]
297    BlockNotFound,
298    #[error("block fingerprint mismatch; projection is stale")]
299    BlockFingerprintMismatch,
300    #[error("list item {ordinal} not found")]
301    ItemNotFound { ordinal: u32 },
302    #[error("edit operation is not supported for this block: {reason}")]
303    UnsupportedEditOperation { reason: String },
304    #[error("invalid edit payload: {reason}")]
305    InvalidEditPayload { reason: String },
306}
307
308/// Parses a GFM table source string into (headers, rows) for the projection.
309fn parse_simple_table(source: &str) -> (Vec<String>, Vec<Vec<String>>) {
310    let parse_row = |line: &str| -> Vec<String> {
311        let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
312        trimmed.split('|').map(|c| c.trim().to_string()).collect()
313    };
314    let is_sep = |line: &str| {
315        let t = line.trim();
316        t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) && t.contains('-')
317    };
318    let mut rows = source.lines().filter(|l| !is_sep(l)).map(parse_row);
319    let headers = rows.next().unwrap_or_default();
320    (headers, rows.collect())
321}