Skip to main content

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}