text_document/batch.rs
1//! [`BatchDocument`] — a headless, thread-free document for backend batch work.
2//!
3//! [`TextDocument`](crate::TextDocument) is built for a *view*. Constructing one spins
4//! up a dedicated OS thread (`EventHubClient::start`, the only `thread::spawn` in the
5//! crate) so a widget can be notified of changes asynchronously. That is exactly right
6//! for an editor and exactly wrong for a batch: a backend rewriting a character's name
7//! across a manuscript would spawn one thread per touched scene to do work it wants to
8//! do inline anyway.
9//!
10//! `BatchDocument` is the same document machinery without the view's apparatus. It
11//! holds an [`AppContext`] — which is itself thread-free — and drives the same
12//! `frontend::commands` entry points `TextDocument` drives, so there is no parallel
13//! implementation to drift out of step. What it never does is start an
14//! `EventHubClient`, so it costs no thread.
15//!
16//! It is deliberately *not* a `TextDocument`: no cursor, no layout, no highlighting,
17//! no view. A caller who wants those wants the real thing. What it has is the parser,
18//! the format runs, the search and the exporter — which is all a search-and-replace
19//! needs, and all it should be able to reach.
20//!
21//! ```ignore
22//! let batch = BatchDocument::new();
23//! batch.set_djot(&content.data, &DjotImportOptions::default())?;
24//! let hits = batch.find_all("Aurélien", &FindOptions::default())?;
25//! let djot = batch.to_djot(&DjotExportOptions::default())?;
26//! ```
27
28use frontend::AppContext;
29use frontend::commands::{
30 document_commands, document_io_commands, document_search_commands, root_commands,
31};
32use frontend::common::parser_tools::{DjotExportOptions, DjotImportOptions};
33use frontend::document::dtos::CreateDocumentDto;
34use frontend::document_io::ImportDjotDto;
35use frontend::root::dtos::CreateRootDto;
36
37use crate::error::Result;
38use crate::{FindMatch, FindOptions, ReplaceOptions, ReplaceRange};
39
40/// A headless document: no thread, no view, no cursor. See the module docs.
41pub struct BatchDocument {
42 ctx: AppContext,
43}
44
45impl BatchDocument {
46 /// Bootstrap an empty document. No I/O, no thread.
47 ///
48 /// `AppContext::new()` gives the machinery (store, event hub, undo manager) but no
49 /// entities — so, like `TextDocument`, this seeds the `Root → Document` the
50 /// importer needs to hang its frames from. It stops there: `TextDocument` also
51 /// seeds a Frame and a Block for an empty editor to show a caret in, but an import
52 /// rebuilds the document's frames anyway, and a batch has no caret to place.
53 ///
54 /// `stack_id: None` — a batch has no undo of its own. Its caller's transaction is
55 /// the unit that gets rolled back.
56 pub fn new() -> Result<Self> {
57 let ctx = AppContext::new();
58 let root = root_commands::create_orphan_root(&ctx, &CreateRootDto::default())?;
59 document_commands::create_document(&ctx, None, &CreateDocumentDto::default(), root.id, -1)?;
60 Ok(Self { ctx })
61 }
62
63 /// Replace the document's contents by parsing `djot`.
64 ///
65 /// Goes through the **synchronous** import path. The public `import_djot` hands
66 /// the use case to a `LongOperationManager`, which spawns a thread for it — the
67 /// very thing this type exists to avoid.
68 pub fn set_djot(&self, djot: &str, options: &DjotImportOptions) -> Result<()> {
69 document_io_commands::import_djot_sync(
70 &self.ctx,
71 &ImportDjotDto {
72 djot_text: djot.to_string(),
73 options: *options,
74 },
75 )?;
76 Ok(())
77 }
78
79 /// Serialise the document back to Djot.
80 pub fn to_djot(&self, options: &DjotExportOptions) -> Result<String> {
81 Ok(document_io_commands::export_djot(&self.ctx, options)?.djot_text)
82 }
83
84 /// Every match of `query` in the document's text.
85 ///
86 /// Positions are **char indices into the document's own text** — the same space
87 /// every other offset in this crate lives in. They are emphatically *not* byte
88 /// offsets into the Djot source the document was parsed from: markup means the two
89 /// disagree on almost any real paragraph, and mixing them corrupts a replace.
90 pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
91 let result =
92 document_search_commands::find_all(&self.ctx, &options.to_find_all_dto(query))?;
93 // The same conversion `TextDocument::find_all` uses. Written twice, the two would
94 // drift — and a batch search that disagreed with the live one about what it matched
95 // is precisely the bug this crate keeps re-learning.
96 Ok(crate::convert::find_all_to_matches(&result))
97 }
98
99 /// Find every match of `query` and let `decide` choose what each becomes — the whole
100 /// thing in one shot.
101 ///
102 /// `decide` is handed the matched text and the index of the match, and returns the
103 /// replacement, or `None` to leave that occurrence alone. That is exactly what a reviewed
104 /// bulk rename needs: skip the occurrences the writer unticked, and preserve the case of
105 /// the ones that stay (`AURÉLIEN` → `AURÉLIAN`, not `aurélian`).
106 ///
107 /// **This is why a backend batch can stop doing string surgery on markup.** The
108 /// alternative — export the Djot and rewrite it as a string — rewrites a query that also
109 /// occurs inside a link's URL, and drops the character formatting under every match. Here
110 /// the edit happens *inside the document*, at the offsets the parser itself reports, and
111 /// `ReplaceOptions::format_policy` decides what the replacement wears where it overwrites
112 /// formatted prose.
113 ///
114 /// The matched text comes back from the scan itself, sliced from the very text that was
115 /// searched — never from a plain-text export, which is the human-readable view and does
116 /// not carry the `U+FFFC` anchor an embedded table occupies.
117 pub fn find_and_replace(
118 &self,
119 query: &str,
120 options: &ReplaceOptions,
121 mut decide: impl FnMut(&str, usize) -> Option<String>,
122 ) -> Result<usize> {
123 let found =
124 document_search_commands::find_all(&self.ctx, &options.find.to_find_all_dto(query))?;
125
126 let mut ranges: Vec<ReplaceRange> = Vec::new();
127 for (i, ((&position, &length), matched)) in found
128 .positions
129 .iter()
130 .zip(found.lengths.iter())
131 .zip(found.matched_texts.iter())
132 .enumerate()
133 {
134 if let Some(replacement) = decide(matched, i) {
135 ranges.push(ReplaceRange {
136 position: position as usize,
137 length: length as usize,
138 replacement,
139 });
140 }
141 }
142
143 if ranges.is_empty() {
144 return Ok(0);
145 }
146 self.replace_ranges(&ranges, options)
147 }
148
149 /// Replace an explicit set of ranges, each with its own replacement text.
150 ///
151 /// Ranges that straddle a block boundary, or that overlap one another, are skipped; the
152 /// returned count is what was actually applied. Prefer [`Self::find_and_replace`], which
153 /// derives the ranges from a scan of the document as it stands, so they cannot address
154 /// text that has since moved.
155 pub fn replace_ranges(
156 &self,
157 ranges: &[ReplaceRange],
158 options: &ReplaceOptions,
159 ) -> Result<usize> {
160 // `stack_id: None` — a batch has no undo of its own; its caller owns the undo entry.
161 let result = document_search_commands::replace_ranges(
162 &self.ctx,
163 None,
164 &options.to_replace_ranges_dto(ranges),
165 )?;
166 Ok(result.replacements_count.max(0) as usize)
167 }
168}