1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
//! `BatchDocument` — the headless, thread-free document a backend batch uses.
//!
//! The point of this type is that it is *cheap enough to make hundreds of*. So these
//! tests check two things: that it round-trips Djot faithfully (a replace that mangles
//! markup is worse than no replace at all), and that it genuinely costs no thread —
//! because if it did, a project-wide rename would spawn one per touched scene.
use text_document::{BatchDocument, DjotExportOptions, DjotImportOptions, FindOptions};
fn round_trip(djot: &str) -> String {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot(djot, &DjotImportOptions::default())
.expect("set_djot");
batch
.to_djot(&DjotExportOptions::default())
.expect("to_djot")
}
/// Prose survives the trip. This is the load-bearing property: a replace parses the
/// Djot, rewrites text, and serialises it back — so anything the round trip damages,
/// a replace would damage across the whole manuscript.
#[test]
fn a_batch_document_round_trips_djot() {
for source in [
"Just a plain paragraph.",
"A paragraph with *emphasis* and _more_ of it.",
"# A heading\n\nAnd a paragraph beneath it.",
"One paragraph.\n\nAnd a second one.",
] {
let out = round_trip(source);
assert_eq!(
out.trim(),
source.trim(),
"round-tripping this Djot changed it:\n in: {source:?}\n out: {out:?}"
);
}
}
/// A **tight** list comes back **loose** — the one place the round trip is not an
/// identity. Pinned rather than wished away, because a `BatchDocument` is what a
/// project-wide replace runs through, and this is the shape of the only reformatting
/// it can cause.
///
/// This is a documented limitation of the model, not a bug in this type: the entity
/// model has no tight/loose distinction, and the exporter's blank line between blocks
/// is what lets an indented sub-list *nest* instead of folding into its parent item's
/// paragraph (see `export_djot_uc`'s own comment). Removing it would trade this for a
/// worse bug.
///
/// It is also **not new to replace**: the editor round-trips a scene's Djot through a
/// document on every flush, so opening and editing a scene already loosens its tight
/// lists. Replace adds no damage that saving does not already do. Fixing it properly
/// means teaching the entity model about tight lists — a separate job.
#[test]
fn a_tight_list_comes_back_loose_and_that_is_known() {
let out = round_trip("- a list item\n- another one");
assert_eq!(
out.trim(),
"- a list item\n\n- another one",
"if this now round-trips tightly, the model learned tight/loose — delete this \
test and fold the case back into `a_batch_document_round_trips_djot`"
);
}
/// The whole reason this type exists. `TextDocument` starts an `EventHubClient` thread
/// per document; `BatchDocument` must not — otherwise a rename touching 120 scenes
/// spawns 120 threads.
///
/// Counted from the process's own thread count rather than mocked, so it fails if a
/// future refactor quietly reintroduces the spawn. Platform-specific: `/proc` on Linux,
/// `task_threads` on macOS, Toolhelp32 on Windows (CI runs all three).
#[test]
fn a_batch_document_costs_no_thread() {
let before = process_thread_count();
let docs: Vec<BatchDocument> = (0..32)
.map(|i| {
let b = BatchDocument::new().expect("BatchDocument::new");
b.set_djot(
&format!("Scene {i}: she called his name into the trees."),
&DjotImportOptions::default(),
)
.expect("set_djot");
b
})
.collect();
let after = process_thread_count();
assert_eq!(docs.len(), 32);
assert!(
after <= before + 2,
"32 BatchDocuments started {} extra thread(s) ({before} -> {after}). \
BatchDocument exists precisely so a batch does not pay a thread per document; \
something reintroduced EventHubClient::start.",
after.saturating_sub(before)
);
}
/// Live thread count of this process. Used only by `a_batch_document_costs_no_thread`.
fn process_thread_count() -> usize {
#[cfg(target_os = "linux")]
{
// Linux exposes the live thread count of the process directly.
std::fs::read_to_string("/proc/self/status")
.expect("/proc/self/status")
.lines()
.find_map(|l| l.strip_prefix("Threads:"))
.and_then(|v| v.trim().parse().ok())
.expect("Threads: line")
}
#[cfg(target_os = "macos")]
{
// `task_threads` lists every live thread of the current Mach task.
type MachPort = u32;
type KernReturn = i32;
type MachMsgTypeNumber = u32;
// Edition 2024: FFI declarations live in `unsafe extern`.
unsafe extern "C" {
static mut mach_task_self_: MachPort;
fn task_threads(
target_task: MachPort,
act_list: *mut *mut MachPort,
act_list_cnt: *mut MachMsgTypeNumber,
) -> KernReturn;
fn vm_deallocate(target_task: MachPort, address: usize, size: usize) -> KernReturn;
fn mach_port_deallocate(task: MachPort, name: MachPort) -> KernReturn;
}
// SAFETY: task_threads fills thread_list; we free each port and the array.
unsafe {
let task = mach_task_self_;
let mut thread_list: *mut MachPort = std::ptr::null_mut();
let mut thread_count: MachMsgTypeNumber = 0;
let kr = task_threads(task, &mut thread_list, &mut thread_count);
assert_eq!(kr, 0, "task_threads failed with kern_return {kr}");
let count = thread_count as usize;
for i in 0..count {
let _ = mach_port_deallocate(task, *thread_list.add(i));
}
let _ = vm_deallocate(
task,
thread_list as usize,
count * std::mem::size_of::<MachPort>(),
);
count
}
}
#[cfg(target_os = "windows")]
{
// Toolhelp32 snapshot of every thread, filtered to this process.
#[repr(C)]
struct ThreadEntry32 {
dw_size: u32,
cnt_usage: u32,
th32_thread_id: u32,
th32_owner_process_id: u32,
tp_base_pri: i32,
tp_delta_pri: i32,
dw_flags: u32,
}
const TH32CS_SNAPTHREAD: u32 = 0x0000_0004;
const INVALID_HANDLE_VALUE: isize = -1;
// Edition 2024: FFI declarations live in `unsafe extern`.
unsafe extern "system" {
fn CreateToolhelp32Snapshot(flags: u32, process_id: u32) -> isize;
fn Thread32First(snapshot: isize, entry: *mut ThreadEntry32) -> i32;
fn Thread32Next(snapshot: isize, entry: *mut ThreadEntry32) -> i32;
fn CloseHandle(handle: isize) -> i32;
fn GetCurrentProcessId() -> u32;
}
// SAFETY: Win32 Toolhelp32 snapshot walk; snapshot is closed before return.
unsafe {
let pid = GetCurrentProcessId();
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
assert!(
snap != 0 && snap != INVALID_HANDLE_VALUE,
"CreateToolhelp32Snapshot failed"
);
let mut entry = std::mem::zeroed::<ThreadEntry32>();
entry.dw_size = std::mem::size_of::<ThreadEntry32>() as u32;
let mut count = 0usize;
if Thread32First(snap, &mut entry) != 0 {
loop {
if entry.th32_owner_process_id == pid {
count += 1;
}
if Thread32Next(snap, &mut entry) == 0 {
break;
}
}
}
let _ = CloseHandle(snap);
count
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
compile_error!(
"process_thread_count is only implemented for linux, macos, and windows (CI targets)"
);
}
}
/// Search runs against the *parsed text*, not the Djot source — so markup never matches
/// and never shifts an offset. `*Aurélien*` must be found at the position of the name,
/// not the position of the asterisk.
#[test]
fn find_all_searches_the_prose_not_the_markup() {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot(
"She called *Aurélien* into the trees, and Aurélien did not answer.",
&DjotImportOptions::default(),
)
.expect("set_djot");
let hits = batch
.find_all("Aurélien", &FindOptions::default())
.expect("find_all");
assert_eq!(
hits.len(),
2,
"both occurrences, including the emphasised one"
);
// The first hit must sit where the NAME is in the prose ("She called " = 11 chars),
// not where it is in the source (which the `*` would push along by one).
assert_eq!(
hits[0].position, 11,
"the offset must be into the parsed text; a Djot-source offset would be 12 \
(the emphasis marker) and would corrupt any replace built on it"
);
assert_eq!(hits[0].length, "Aurélien".chars().count());
}
/// Reusing one document across several imports must not accumulate the old contents —
/// a batch loop refills the same document for every scene it touches.
#[test]
fn set_djot_replaces_rather_than_appends() {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot("the first scene", &DjotImportOptions::default())
.expect("first");
batch
.set_djot("the second scene", &DjotImportOptions::default())
.expect("second");
let out = batch
.to_djot(&DjotExportOptions::default())
.expect("to_djot");
assert!(out.contains("second"), "the newest import must be present");
assert!(
!out.contains("first"),
"the previous import must be gone, not appended: {out:?}"
);
}