cloudillo_search/prune.rs
1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Deleting manifest-named nodes from a document before extraction.
5//!
6//! A part rule's `prune` list ([`crate::rules::PartRule::prune`]) names nodes
7//! that are *not text*. They are deleted from the exported document before
8//! [`crate::extract`] walks it, so a rule's `keys` allowlist only ever sees prose.
9//!
10//! # Why by position and not by name
11//!
12//! `keys` gates a leaf by its enclosing object key, which cannot separate the
13//! members of a positional tuple: notillo stores a styled run as
14//! `["szöveg", "b"]`, where slot 1 is a style flag drawn from the closed
15//! vocabulary `b i u s c`. Every such flag is a subsequence of `"biusc"`, so
16//! `"is"` and `"bus"` — real words — become index tokens and cause false hits.
17//! Both slots share one enclosing key, so no allowlist can tell them apart. A
18//! JSONPath *selector* could name slot 0, but [`crate::extract::extract_fields`]
19//! appends every rule's output into one sink in declaration order, so splitting
20//! one prose stream across several rules scrambles reading order. Deleting the
21//! tail slot up front leaves one rule, one walk, one reading order.
22//!
23//! # Why deletion-only is safe
24//!
25//! Pruning can only ever *remove* text: it loses no text it was not aimed at and
26//! fabricates nothing, so a pattern that fails at run time degrades to "a few
27//! style-flag tokens survive". That is why a failure here is a `warn!` rather
28//! than an error: propagating would abort a scheduler task that retries on a
29//! timer forever, for a manifest problem that only degrades the index.
30//!
31//! Object deletion does not preserve order, though: under
32//! `serde_json/preserve_order` `Map::remove` is a *swap*-remove, which is what
33//! `jsonpath-rust` calls, so the object's last key lands in the freed slot and its
34//! siblings' extraction order shifts. Array-element deletion keeps order — one more
35//! reason to prefer the slice patterns below.
36//!
37//! # Slices, not wildcards
38//!
39//! **Write `[0:]`, not `[*]`.** `jsonpath-rust`'s slice selector is inert on a
40//! non-array while its wildcard descends objects as well; see
41//! [`crate::rules`] for the notillo table shape that makes the difference bite.
42//!
43//! # Cost
44//!
45//! Each pattern is a whole extra traversal of the document, plus one allocated
46//! normalised path per match and one reparse of that path inside
47//! `delete_by_path`. [`crate::rules`]'s `MAX_PRUNE_RULES` is the only bound:
48//! `MAX_JSONPATH_NODES` does **not** apply here, because a deletion's match set is
49//! built inside `delete_by_path` where there is no hook to cap it. Peak extra
50//! memory is one short `String` per match, against a document already fully
51//! resident as a [`Value`].
52
53use jsonpath_rust::query::queryable::Queryable as _;
54use serde_json::Value;
55
56use crate::{indexer::split_path, prelude::*, rules::IndexRules};
57
58/// Apply one part rule's prune list to one document, in declaration order.
59///
60/// Each pattern re-queries the already-mutated document, so a later pattern sees
61/// what the earlier ones left. Returns `(deleted, failed)`: nodes actually
62/// removed, and patterns that could not be evaluated.
63///
64/// A failing pattern is skipped rather than propagated — see the module docs on
65/// why partial pruning is harmless. It leaves the document *less* pruned, never
66/// wrong: no text is lost and nothing is fabricated (on ordering, see the module
67/// note on swap-remove).
68pub fn prune_document(doc: &mut Value, patterns: &[String]) -> (usize, usize) {
69 let mut deleted = 0;
70 let mut failed = 0;
71 for pattern in patterns {
72 match doc.delete_by_path(pattern) {
73 Ok(n) => deleted += n,
74 // Not warned here: one broken pattern against a 5000-block document
75 // would emit 5000 identical lines. `prune_docs` aggregates instead.
76 Err(_) => failed += 1,
77 }
78 }
79 (deleted, failed)
80}
81
82/// Prune every exported document against the rules for its kind.
83///
84/// Applies **the union of every matching part rule's prune list, in `parts`
85/// declaration order**, rather than one rule's list at a time:
86///
87/// 1. Prune is a property of the document *shape*, not of the row a rule emits —
88/// two rules over one kind read the same JSON.
89/// 2. Per-rule pruning would need a clone per rule, and would destroy "prune
90/// once, both of `build_parts`' passes see it".
91/// 3. Deletion-only means a union can only remove *more*; it cannot make either
92/// rule's output wrong in a way its own list would not have, only shorter.
93/// 4. Declaration order is fixed, so the result is deterministic.
94///
95/// There is deliberately no validation forbidding two rules of one kind from both
96/// declaring `prune`: the union is already well defined, no shipped manifest has
97/// that shape, and it would be one more rule to defend.
98pub fn prune_docs(rules: &IndexRules, docs: &mut [(Box<str>, Value)], tn_id: TnId, file_id: &str) {
99 // The case that must cost nothing: no manifest but notillo's declares a prune
100 // list. At most `MAX_PART_RULES` emptiness checks, once per document *set*.
101 if !rules.parts.iter().any(|p| !p.prune.is_empty()) {
102 return;
103 }
104
105 let mut deleted = 0;
106 let mut failed = 0;
107 // The last pattern that failed, so the aggregated warning can name one.
108 let mut culprit: Option<&str> = None;
109
110 for (path, doc) in docs.iter_mut() {
111 let Some((kind, _)) = split_path(path) else { continue };
112 // A linear scan per document, mirroring `indexer`'s own pass 2. With at
113 // most `MAX_PART_RULES` rules, and gated by the emptiness check above, it
114 // is far below the JSONPath work it guards.
115 for rule in rules.parts.iter().filter(|p| p.kind == kind) {
116 let (d, f) = prune_document(doc, &rule.prune);
117 deleted += d;
118 if f > 0 {
119 failed += f;
120 culprit = rule.prune.first().map(String::as_str);
121 }
122 }
123 }
124
125 if failed > 0 {
126 warn!(tn_id = %tn_id, file_id, failed, pattern = culprit.unwrap_or(""),
127 "Search prune pattern failed");
128 }
129 // The crate's first mechanism that destroys text silently — everything else
130 // sets `TextSink::truncated`. A mistyped pattern's only other symptom is a
131 // search that quietly stops finding things.
132 debug!(tn_id = %tn_id, file_id, deleted, "Search prune");
133}
134
135#[cfg(test)]
136mod tests {
137 use serde_json::json;
138
139 use super::*;
140
141 /// The two patterns `apps/notillo/src/manifest.ts` actually ships, so these
142 /// tests exercise the shipped strings rather than a paraphrase.
143 fn notillo_patterns() -> Vec<String> {
144 vec!["$..c[0:][1:]".to_owned(), "$..cells[0:][0:][1:]".to_owned()]
145 }
146
147 fn pruned(mut doc: Value) -> Value {
148 let (_, failed) = prune_document(&mut doc, ¬illo_patterns());
149 assert_eq!(failed, 0, "the shipped patterns must evaluate");
150 doc
151 }
152
153 /// **The critical test.** A table block stores its content as an *object*
154 /// under the same `c` key an inline block uses for its array. With `[*]` in
155 /// place of `[0:]` the wildcard would descend that object, reach `rows`, and
156 /// delete every row but the first — and every column width but the first.
157 #[test]
158 fn a_table_block_keeps_every_row_and_column_width() {
159 let out = pruned(json!({
160 "c": { "type": "tableContent", "cw": [100, 200, 150], "hr": 1,
161 "rows": [
162 { "cells": [["Alma"], ["Körte"]] },
163 { "cells": [["Szilva"], ["Barack"]] }
164 ] }
165 }));
166 assert_eq!(out["c"]["rows"].as_array().map(Vec::len), Some(2), "got {out}");
167 assert_eq!(out["c"]["cw"].as_array().map(Vec::len), Some(3), "got {out}");
168 let text = out.to_string();
169 for word in ["Alma", "Körte", "Szilva", "Barack"] {
170 assert!(text.contains(word), "missing {word} in {text}");
171 }
172 }
173
174 #[test]
175 fn a_style_flag_slot_is_deleted_and_its_text_kept() {
176 let out = pruned(json!({
177 "c": ["Sima ", ["félkövér", "b"], ["dőlt és aláhúzott", "iu"]]
178 }));
179 assert_eq!(out["c"], json!(["Sima ", ["félkövér"], ["dőlt és aláhúzott"]]), "got {out}");
180 }
181
182 /// `[1:]` takes the whole tail, so a colour run loses both its (possibly
183 /// empty) flag slot and the colour object behind it.
184 #[test]
185 fn a_colour_run_loses_its_empty_flag_slot_and_its_colour_object() {
186 let out = pruned(json!({ "c": [["piros", "", { "tc": "#f00" }]] }));
187 assert_eq!(out["c"], json!([["piros"]]), "got {out}");
188 }
189
190 /// `[1:]` matches nothing on a string or an object, so every non-tuple inline
191 /// shape passes through whole — while a tuple nested inside a link's own `c`
192 /// is still pruned.
193 #[test]
194 fn links_wiki_links_and_tags_survive_the_prune() {
195 let out = pruned(json!({
196 "c": [
197 { "l": "https://pelda.hu", "c": ["hivatkozás", ["kiemelt", "b"]] },
198 { "wl": "p1", "wt": "Oldalcím" },
199 { "tg": "projekt" }
200 ]
201 }));
202 assert_eq!(
203 out["c"],
204 json!([
205 { "l": "https://pelda.hu", "c": ["hivatkozás", ["kiemelt"]] },
206 { "wl": "p1", "wt": "Oldalcím" },
207 { "tg": "projekt" }
208 ]),
209 "got {out}"
210 );
211 }
212
213 #[test]
214 fn object_form_table_cells_keep_their_props() {
215 let out = pruned(json!({
216 "c": { "type": "tableContent", "rows": [{ "cells": [
217 { "pr": { "backgroundColor": "#ff0000" }, "c": ["Alma", ["Szia", "b"]] },
218 { "c": ["Körte"] }
219 ] }] }
220 }));
221 assert_eq!(
222 out["c"]["rows"][0]["cells"],
223 json!([
224 { "pr": { "backgroundColor": "#ff0000" }, "c": ["Alma", ["Szia"]] },
225 { "c": ["Körte"] }
226 ]),
227 "got {out}"
228 );
229 }
230
231 /// An array-form cell puts its inline content one array level deeper than an
232 /// object-form one, which is what the third `[0:]` is for.
233 #[test]
234 fn array_form_table_cells_lose_only_their_style_slots() {
235 let out = pruned(json!({
236 "c": { "type": "tableContent", "rows": [{ "cells": [
237 ["Alma", ["Szia", "b"]],
238 ["Körte"]
239 ] }] }
240 }));
241 assert_eq!(
242 out["c"]["rows"][0]["cells"],
243 json!([["Alma", ["Szia"]], ["Körte"]]),
244 "got {out}"
245 );
246 }
247
248 /// A true no-op: no `null` left behind, no key removed, nothing reordered.
249 #[test]
250 fn a_document_with_nothing_to_prune_is_returned_unchanged() {
251 let page = json!({ "ti": "Bevezetés", "tg": ["munka"], "pp": "gyoker" });
252 let block = json!({ "p": "page1", "o": 2, "c": ["sima szöveg", { "tg": "projekt" }] });
253 for doc in [page, block] {
254 let before = doc.clone();
255 assert_eq!(pruned(doc), before);
256 }
257 }
258
259 #[test]
260 fn an_empty_prune_list_is_a_no_op() {
261 let mut doc = json!({ "c": [["félkövér", "b"]] });
262 let before = doc.clone();
263 assert_eq!(prune_document(&mut doc, &[]), (0, 0));
264 assert_eq!(doc, before);
265 }
266
267 fn rules(json: &Value) -> IndexRules {
268 IndexRules::parse(json).expect("rules")
269 }
270
271 #[test]
272 fn only_the_kinds_that_declare_a_prune_list_are_touched() {
273 let rules = rules(&json!({
274 "parts": [
275 { "kind": "p", "title": ["ti"] },
276 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
277 "prune": ["$..c[0:][1:]"], "body": ["c"] }
278 ]
279 }));
280 // A page carrying a look-alike tuple under the same key must come back
281 // whole: prune is scoped to the kind that declared it.
282 let mut docs: Vec<(Box<str>, Value)> = vec![
283 ("p/page1".into(), json!({ "ti": "Cím", "c": [["Cím", "b"]] })),
284 ("b/b1".into(), json!({ "p": "page1", "c": [["szöveg", "b"]] })),
285 ];
286 prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
287 assert_eq!(docs[0].1["c"], json!([["Cím", "b"]]), "the page rule declares no prune");
288 assert_eq!(docs[1].1["c"], json!([["szöveg"]]));
289 }
290
291 /// The union decision, made explicit so a future reader does not "fix" it: a
292 /// kind may carry both an emitting and an attaching rule, and both prune lists
293 /// apply to the one document they share.
294 #[test]
295 fn a_kind_with_both_an_emitting_and_an_attaching_rule_gets_both_prune_lists() {
296 let rules = rules(&json!({
297 "parts": [
298 { "kind": "p", "title": ["ti"] },
299 { "kind": "b", "title": ["ti"], "prune": ["$..c[0:][1:]"] },
300 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
301 "prune": ["$..zaj"], "body": ["c"] }
302 ]
303 }));
304 let mut docs: Vec<(Box<str>, Value)> = vec![
305 ("p/page1".into(), json!({ "ti": "Cím" })),
306 ("b/b1".into(), json!({ "p": "page1", "c": [["szöveg", "b"]], "zaj": "törlendő" })),
307 ];
308 prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
309 assert_eq!(docs[1].1["c"], json!([["szöveg"]]));
310 assert!(docs[1].1.get("zaj").is_none(), "got {}", docs[1].1);
311 }
312}
313
314// vim: ts=4