Skip to main content

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, &notillo_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