disarm 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,959 @@
1
+ //! magnus bindings exposing the pure-Rust `disarm` core as the Ruby `Disarm`
2
+ //! module (#45). Every method is a thin wrapper over `disarm_core::api`, so the
3
+ //! security/transform behaviour is defined once in the core and inherited here.
4
+ //!
5
+ //! This file is deliberately the *raw* shim: positional arguments, string scheme
6
+ //! / target tokens, and standard Ruby exceptions. The idiomatic Ruby surface —
7
+ //! keyword arguments, symbol tokens, defaults, the `Disarm::Error` hierarchy, and
8
+ //! the single `transliterate(text, scheme:)` entrypoint — is a thin pure-Ruby
9
+ //! layer in `lib/disarm.rb` that forwards to the `_`-prefixed methods defined
10
+ //! here (#357). Keeping the native side raw avoids fighting magnus's fixed-arity
11
+ //! `function!` over keyword handling.
12
+ //!
13
+ //! Targets magnus 0.8 (Ruby >= 3.1). Build via rake-compiler / rb-sys, not
14
+ //! `cargo build` directly (it needs the Ruby headers rb-sys configures).
15
+
16
+ // S-4: this shim is an FFI boundary that must never panic across into Ruby. Lock
17
+ // that in structurally with the no-panic restriction lints (caught by the binding's
18
+ // clippy gate). The handful of `Ruby::get().expect(...)` calls are GVL invariants
19
+ // (a Ruby callback always holds the GVL) and carry a local `#[allow]` with that
20
+ // justification; everything else must return a magnus `Error`, never panic.
21
+ #![cfg_attr(
22
+ not(test),
23
+ deny(
24
+ clippy::unwrap_used,
25
+ clippy::expect_used,
26
+ clippy::indexing_slicing,
27
+ clippy::string_slice,
28
+ clippy::panic,
29
+ clippy::todo,
30
+ clippy::unimplemented
31
+ )
32
+ )]
33
+
34
+ use std::collections::HashSet;
35
+
36
+ use disarm_core::api;
37
+ use magnus::{function, method, prelude::*, Error, RHash, Ruby};
38
+
39
+ /// Map a `disarm` error onto the closest standard Ruby exception:
40
+ /// `InvalidArgument` → `ArgumentError`, everything else → `RuntimeError`. The
41
+ /// pure-Ruby layer (`lib/disarm.rb`) rescues these and re-raises them as
42
+ /// `Disarm::InvalidArgument` / `Disarm::Error` so consumers can `rescue
43
+ /// Disarm::Error` (#357); raising the built-ins here keeps the native side free
44
+ /// of any dependency on Ruby-defined classes existing at call time.
45
+ ///
46
+ /// Named `map_err` (not `raise`) to signal it constructs a magnus `Error` value
47
+ /// rather than raising immediately — mirrors the Node shim's convention.
48
+ fn map_err(e: &disarm_core::Error) -> Error {
49
+ // magnus 0.8 moved the exception-class constructors onto the `Ruby` handle
50
+ // (Ractor-safety). map_err is only ever called from inside a Ruby method
51
+ // callback, so the GVL is held and `Ruby::get()` cannot fail — a justified
52
+ // exception to the no-panic gate above.
53
+ #[allow(clippy::expect_used)]
54
+ let ruby = Ruby::get().expect("map_err must run while holding the Ruby GVL");
55
+ let class = match e.kind() {
56
+ disarm_core::ErrorKind::InvalidArgument => ruby.exception_arg_error(),
57
+ _ => ruby.exception_runtime_error(),
58
+ };
59
+ Error::new(class, e.to_string())
60
+ }
61
+
62
+ // ── Malformed-Unicode boundary (#469 / #472) ──────────────────────────────────
63
+ //
64
+ // Ruby has no "lone surrogate" scalar; the equivalent malformed input is a String
65
+ // tagged UTF-8 whose bytes are not valid UTF-8 — typically WTF-8, where a surrogate
66
+ // code point is the (forbidden) 3-byte sequence `ED A0–BF 80–BF`. magnus's
67
+ // `String` conversion validates UTF-8 and raises `EncodingError` on such bytes, so
68
+ // every entrypoint would reject it. [`Wtf8Text`] takes the raw `RString` bytes
69
+ // *before* that validation and brings them to the same WTF-8 → UTF-8 contract the
70
+ // Python and Node bindings honor: a well-formed high+low pair recombines into its
71
+ // astral scalar, and each genuinely lone surrogate code unit (or non-decodable byte)
72
+ // becomes exactly one `U+FFFD`. Valid UTF-8 skips the WTF-8 decoder via a `from_utf8`
73
+ // check (it still allocates the owned `String` magnus would have).
74
+
75
+ /// Decode WTF-8 bytes to valid UTF-8: recombine surrogate pairs into astral scalars,
76
+ /// map each lone surrogate — and any byte that is not part of a valid WTF-8 sequence —
77
+ /// to one `U+FFFD`. (Per code unit, not Ruby's per-byte `String#scrub`.)
78
+ fn wtf8_to_utf8(bytes: &[u8]) -> String {
79
+ // A continuation byte (`10xx_xxxx`); its low 6 bits, or `None` if absent/not a
80
+ // continuation. Slice access is via `get` so the binding's no-panic gate holds.
81
+ #[inline]
82
+ fn cont(b: Option<&u8>) -> Option<u32> {
83
+ b.filter(|&&x| x & 0xC0 == 0x80)
84
+ .map(|&x| u32::from(x & 0x3F))
85
+ }
86
+ // Pass 1: decode to code points, allowing surrogate scalars. A byte that does not
87
+ // begin a *valid* 1–4 byte WTF-8 sequence — including an overlong encoding (e.g. the
88
+ // 2-byte `C0 AF` for `/`) or an out-of-range 4-byte lead (`F5..F7`, > U+10FFFF) —
89
+ // yields one U+FFFD and advances by a single byte, so the following bytes are
90
+ // re-examined individually rather than swallowed.
91
+ let mut cps: Vec<u32> = Vec::with_capacity(bytes.len());
92
+ let mut i = 0;
93
+ while let Some(&b) = bytes.get(i) {
94
+ let c1 = cont(bytes.get(i + 1));
95
+ let c2 = cont(bytes.get(i + 2));
96
+ let c3 = cont(bytes.get(i + 3));
97
+ let (cp, len) = if b < 0x80 {
98
+ (u32::from(b), 1)
99
+ } else if let (0b110, Some(x1)) = (b >> 5, c1) {
100
+ let cp = (u32::from(b & 0x1F) << 6) | x1;
101
+ // reject overlong (a 2-byte form for < U+0080 — lead C0/C1).
102
+ if cp >= 0x80 {
103
+ (cp, 2)
104
+ } else {
105
+ (0xFFFD, 1)
106
+ }
107
+ } else if let (0b1110, Some(x1), Some(x2)) = (b >> 4, c1, c2) {
108
+ let cp = (u32::from(b & 0x0F) << 12) | (x1 << 6) | x2;
109
+ // reject overlong (< U+0800); surrogates U+D800..U+DFFF are valid WTF-8.
110
+ if cp >= 0x800 {
111
+ (cp, 3)
112
+ } else {
113
+ (0xFFFD, 1)
114
+ }
115
+ } else if let (0b11110, Some(x1), Some(x2), Some(x3)) = (b >> 3, c1, c2, c3) {
116
+ let cp = (u32::from(b & 0x07) << 18) | (x1 << 12) | (x2 << 6) | x3;
117
+ // reject overlong (< U+10000) and out-of-range (> U+10FFFF — lead F5..F7).
118
+ if (0x1_0000..=0x10_FFFF).contains(&cp) {
119
+ (cp, 4)
120
+ } else {
121
+ (0xFFFD, 1)
122
+ }
123
+ } else {
124
+ (0xFFFD, 1)
125
+ };
126
+ cps.push(cp);
127
+ i += len;
128
+ }
129
+ // Pass 2: recombine high+low surrogate pairs into the astral scalar; a lone
130
+ // surrogate becomes one U+FFFD.
131
+ let mut out = String::with_capacity(bytes.len());
132
+ let mut j = 0;
133
+ while let Some(&cp) = cps.get(j) {
134
+ let low = cps
135
+ .get(j + 1)
136
+ .copied()
137
+ .filter(|n| (0xDC00..=0xDFFF).contains(n));
138
+ if let (true, Some(lo)) = ((0xD800..=0xDBFF).contains(&cp), low) {
139
+ let astral = 0x1_0000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
140
+ out.push(char::from_u32(astral).unwrap_or('\u{FFFD}'));
141
+ j += 2;
142
+ } else {
143
+ out.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); // surrogate -> None -> U+FFFD
144
+ j += 1;
145
+ }
146
+ }
147
+ out
148
+ }
149
+
150
+ /// A text argument decoded at the boundary with the WTF-8 → UTF-8 contract (#472).
151
+ /// Used in place of `String` for every text parameter; `Deref<Target = str>` lets the
152
+ /// existing `&text` call sites reach the core unchanged.
153
+ struct Wtf8Text(String);
154
+
155
+ impl std::ops::Deref for Wtf8Text {
156
+ type Target = str;
157
+ fn deref(&self) -> &str {
158
+ &self.0
159
+ }
160
+ }
161
+
162
+ impl magnus::TryConvert for Wtf8Text {
163
+ fn try_convert(val: magnus::Value) -> Result<Self, Error> {
164
+ let s = magnus::RString::try_convert(val)?;
165
+ // SAFETY: the bytes are copied out immediately; no Ruby API runs between
166
+ // `as_slice` and `to_vec`, so the string cannot be moved or collected.
167
+ let bytes = unsafe { s.as_slice().to_vec() };
168
+ let decoded = match std::str::from_utf8(&bytes) {
169
+ Ok(valid) => valid.to_owned(),
170
+ Err(_) => wtf8_to_utf8(&bytes),
171
+ };
172
+ Ok(Wtf8Text(decoded))
173
+ }
174
+ }
175
+
176
+ // ── Transliteration ───────────────────────────────────────────────────────────
177
+
178
+ /// `Disarm._transliterate(text)` — Unicode → ASCII with the default scheme (the
179
+ /// common case; keeps the core's borrow-on-no-op fast path).
180
+ fn transliterate(text: Wtf8Text) -> String {
181
+ api::transliterate(&text).into_owned()
182
+ }
183
+
184
+ /// `Disarm._transliterate_opts(text, "default" | "strict_iso9" | "gost7034", lang)`
185
+ /// — a scheme and/or a language profile via the core's `Transliterate` builder.
186
+ /// `lang` is `nil` (no profile) or a code like `"uk"` (Київ → Kyiv); it composes
187
+ /// with the scheme. The idiomatic layer routes the bare-default/no-lang case to
188
+ /// `_transliterate` so this is only hit when at least one option is set.
189
+ fn transliterate_opts(
190
+ text: Wtf8Text,
191
+ scheme: String,
192
+ lang: Option<String>,
193
+ ) -> Result<String, Error> {
194
+ let mut builder = api::Transliterate::new();
195
+ if scheme != "default" {
196
+ let scheme: api::Scheme = scheme.parse().map_err(|e| map_err(&e))?;
197
+ builder = builder.scheme(scheme);
198
+ }
199
+ if let Some(lang) = lang {
200
+ builder = builder.lang(lang);
201
+ }
202
+ Ok(builder.run(&text).into_owned())
203
+ }
204
+
205
+ // ── Confusables (TR39) ────────────────────────────────────────────────────────
206
+
207
+ /// `Disarm._normalize_confusables(text, "latin" | "cyrillic" | "arabic" | "hebrew")`.
208
+ fn normalize_confusables(
209
+ text: Wtf8Text,
210
+ target: String,
211
+ digit_policy: String,
212
+ ) -> Result<String, Error> {
213
+ let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
214
+ let digit_policy: api::DigitPolicy = digit_policy.parse().map_err(|e| map_err(&e))?;
215
+ Ok(api::normalize_confusables_with(&text, target, digit_policy).into_owned())
216
+ }
217
+
218
+ /// `Disarm._confusable?(text, "latin" | "cyrillic" | "arabic" | "hebrew")`.
219
+ fn is_confusable(text: Wtf8Text, target: String) -> Result<bool, Error> {
220
+ let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
221
+ Ok(api::is_confusable(&text, target))
222
+ }
223
+
224
+ /// `Disarm._unmapped_confusables(target)` — every upstream confusable source the
225
+ /// bundled table does not fold (#563).
226
+ fn unmapped_confusables(target: String) -> Result<Vec<String>, Error> {
227
+ let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
228
+ Ok(api::unmapped_confusables(target)
229
+ .into_iter()
230
+ .map(String::from)
231
+ .collect())
232
+ }
233
+
234
+ /// `Disarm._find_unmapped_confusables(text, target)` — unfolded confusable sources in
235
+ /// `text`, as `[char, byte_offset]` pairs (the Ruby layer maps these to
236
+ /// `{ char:, offset: }` hashes, matching `find_untranslatable`).
237
+ fn find_unmapped_confusables(
238
+ text: Wtf8Text,
239
+ target: String,
240
+ ) -> Result<Vec<(String, usize)>, Error> {
241
+ let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
242
+ Ok(api::find_unmapped_confusables(&text, target)
243
+ .into_iter()
244
+ .map(|u| (u.ch.to_string(), u.offset))
245
+ .collect())
246
+ }
247
+
248
+ // ── Canonicalization primitives ───────────────────────────────────────────────
249
+
250
+ fn strip_accents(text: Wtf8Text) -> String {
251
+ api::strip_accents(&text).into_owned()
252
+ }
253
+
254
+ fn fold_case(text: Wtf8Text) -> String {
255
+ api::fold_case(&text).into_owned()
256
+ }
257
+
258
+ /// `Disarm._is_case_fold_stable?(text)` — whether case folding and simple
259
+ /// lowercasing agree, so the value is a stable identity key (#619).
260
+ fn is_case_fold_stable(text: Wtf8Text) -> bool {
261
+ api::is_case_fold_stable(&text)
262
+ }
263
+
264
+ /// `Disarm._find_key_collisions(values, key, lang)` — which values share an
265
+ /// identity key (#620), as the tuple list `lib/disarm.rb` maps to named hashes.
266
+ /// Same rationale as `AnomalyReportTuple`: a tuple beats registering a Ruby class
267
+ /// for an internal boundary.
268
+ type KeyCollisionTuple = (String, Vec<String>, Vec<usize>);
269
+
270
+ fn find_key_collisions(
271
+ values: Vec<String>,
272
+ key: String,
273
+ lang: Option<String>,
274
+ ) -> Result<Vec<KeyCollisionTuple>, Error> {
275
+ let key: api::KeyForm = key.parse().map_err(|e| map_err(&e))?;
276
+ Ok(api::find_key_collisions(&values, key, lang.as_deref())
277
+ .map_err(|e| map_err(&e))?
278
+ .into_iter()
279
+ .map(|c| (c.key, c.values, c.indices))
280
+ .collect())
281
+ }
282
+
283
+ /// `Disarm._slugify(text, …)` — the full slug option surface, positional. The
284
+ /// Ruby layer maps its keyword arguments (with the core's documented defaults)
285
+ /// onto this order. `regex_pattern` and `replacements` are intentionally not
286
+ /// surfaced yet (they need non-scalar Ruby↔Rust conversion); everything else the
287
+ /// core's `SlugConfig` exposes is reachable.
288
+ #[allow(clippy::too_many_arguments)]
289
+ fn slugify(
290
+ text: Wtf8Text,
291
+ separator: String,
292
+ lowercase: bool,
293
+ max_length: usize,
294
+ word_boundary: bool,
295
+ save_order: bool,
296
+ stopwords: Vec<String>,
297
+ allow_unicode: bool,
298
+ lang: Option<String>,
299
+ entities: bool,
300
+ decimal: bool,
301
+ hexadecimal: bool,
302
+ safe_chars: String,
303
+ ) -> String {
304
+ let mut config = api::SlugConfig::default()
305
+ .with_separator(separator)
306
+ .with_lowercase(lowercase)
307
+ .with_max_length(max_length)
308
+ .with_word_boundary(word_boundary)
309
+ .with_save_order(save_order)
310
+ .with_stopwords(stopwords)
311
+ .with_allow_unicode(allow_unicode)
312
+ .with_safe_chars(safe_chars);
313
+ if let Some(lang) = lang {
314
+ config = config.with_lang(lang);
315
+ }
316
+ // `entities`/`decimal`/`hexadecimal` have no chainable setter; the fields are
317
+ // public, so set them directly (the core defaults all three to `true`).
318
+ config.entities = entities;
319
+ config.decimal = decimal;
320
+ config.hexadecimal = hexadecimal;
321
+ api::slugify(&text, &config)
322
+ }
323
+
324
+ /// `Disarm._demojize(text, strip_modifiers)`.
325
+ fn demojize(text: Wtf8Text, strip_modifiers: bool) -> String {
326
+ api::demojize(&text, strip_modifiers)
327
+ }
328
+
329
+ // ── Security presets (fallible) ───────────────────────────────────────────────
330
+
331
+ /// Strip the non-interchange and invisible classes while KEEPING the script (#698).
332
+ ///
333
+ /// The seven universal `strip*` primitives cannot be composed into this, and the
334
+ /// difference runs in both directions. `strip_format` is *less* destructive where
335
+ /// rendering matters — it preserves the Private Use Area for icon fonts and keeps the
336
+ /// VS15/VS16 presentation selectors after a base (`RENDERING_STRIP`), both of which the
337
+ /// naive chain deletes — and *more* destructive with whitespace, because it ends in
338
+ /// `CollapseWs` and folds TAB/LF to a space where the primitives leave them. The policy
339
+ /// itself is a private constant, so a caller on this binding could not express it at all.
340
+ ///
341
+ /// Unlike `canonicalize` it does NOT fold confusables, so non-Latin text keeps its script
342
+ /// — the point of the preset.
343
+ fn canonicalize_strict(text: Wtf8Text) -> Result<String, Error> {
344
+ api::canonicalize_strict(&text)
345
+ .map(std::borrow::Cow::into_owned)
346
+ .map_err(|e| map_err(&e))
347
+ }
348
+
349
+ fn strip_format(text: Wtf8Text) -> String {
350
+ api::strip_format(&text).into_owned()
351
+ }
352
+
353
+ fn strip_obfuscation(text: Wtf8Text) -> Result<String, Error> {
354
+ api::strip_obfuscation(&text)
355
+ .map(std::borrow::Cow::into_owned)
356
+ .map_err(|e| map_err(&e))
357
+ }
358
+
359
+ fn canonicalize(text: Wtf8Text) -> Result<String, Error> {
360
+ api::canonicalize(&text)
361
+ .map(std::borrow::Cow::into_owned)
362
+ .map_err(|e| map_err(&e))
363
+ }
364
+
365
+ /// `Disarm._search_key(text, lang)` — case/accent/script-insensitive lookup key.
366
+ /// `lang` is `nil` (no profile) or a code like `"ru"`. Fails on an unknown `lang`.
367
+ fn search_key(text: Wtf8Text, lang: Option<String>) -> Result<String, Error> {
368
+ api::search_key(&text, lang.as_deref())
369
+ .map(std::borrow::Cow::into_owned)
370
+ .map_err(|e| map_err(&e))
371
+ }
372
+
373
+ /// `Disarm._sort_key(text, lang)` — collation sort key (preserves base accented
374
+ /// characters for correct ordering). Fails on an unknown `lang`.
375
+ fn sort_key(text: Wtf8Text, lang: Option<String>) -> Result<String, Error> {
376
+ api::sort_key(&text, lang.as_deref())
377
+ .map(std::borrow::Cow::into_owned)
378
+ .map_err(|e| map_err(&e))
379
+ }
380
+
381
+ /// `Disarm._catalog_key(text, lang, strict_iso9)` — catalog deduplication key.
382
+ /// `strict_iso9` selects the ISO 9:1995 Cyrillic scheme. Fails on an unknown `lang`.
383
+ fn catalog_key(text: Wtf8Text, lang: Option<String>, strict_iso9: bool) -> Result<String, Error> {
384
+ api::catalog_key(&text, lang.as_deref(), strict_iso9)
385
+ .map(std::borrow::Cow::into_owned)
386
+ .map_err(|e| map_err(&e))
387
+ }
388
+
389
+ /// `Disarm._suspicious_hostname?(host)` — flags mixed-script / confusable IDN
390
+ /// spoofs. A false result asserts nothing was *found*, not that the host is safe.
391
+ fn suspicious_hostname(host: Wtf8Text) -> bool {
392
+ // #362 made the Rust api return `HostnameAnalysis` (the verdict is its
393
+ // `suspicious` field) instead of a `(bool, _)` tuple.
394
+ api::is_suspicious_hostname(&host).suspicious
395
+ }
396
+
397
+ /// The full `HostnameAnalysis` (#549) flattened into the tuple shape `lib/disarm.rb`
398
+ /// maps to a named hash — same rationale as `AnomalyReportTuple` (avoid registering
399
+ /// a Ruby class for an internal boundary). magnus converts the nested
400
+ /// `Vec<Vec<String>>` and the `Vec<bool>` to nested Ruby arrays automatically.
401
+ ///
402
+ /// The last two fields are a nested pair rather than two more top-level ones: magnus
403
+ /// implements `IntoValue` for tuples up to arity 12 (`r_array.rs`, `seq!(N in 0..12)`),
404
+ /// and `compat_fold` (#709) was the thirteenth. Ruby destructures the pair in the same
405
+ /// statement, so the hash `analyze_hostname` returns is unchanged. A fourteenth field
406
+ /// should register a class or build an `RArray` rather than nest again.
407
+ #[allow(clippy::type_complexity)]
408
+ type HostnameAnalysisTuple = (
409
+ bool, // suspicious
410
+ Vec<String>, // scripts
411
+ bool, // mixed_script
412
+ bool, // has_confusables
413
+ bool, // bidi_conflict
414
+ bool, // bidi_control
415
+ bool, // has_invisible
416
+ bool, // compat_fold (#709)
417
+ bool, // cross_label_script
418
+ Vec<Vec<String>>, // label_scripts
419
+ bool, // whole_script_confusable
420
+ // Nested to stay inside magnus's arity-12 ceiling; see the doc comment above.
421
+ (
422
+ Vec<bool>, // label_whole_script_confusable
423
+ String, // canonical
424
+ ),
425
+ );
426
+
427
+ /// `Disarm._analyze_hostname(host)` — the full analysis behind the
428
+ /// `suspicious_hostname?` predicate, as the tuple the Ruby layer maps to a hash.
429
+ fn analyze_hostname(host: Wtf8Text, contractions: bool) -> HostnameAnalysisTuple {
430
+ let a = api::analyze_hostname_with(&host, contractions);
431
+ (
432
+ a.suspicious,
433
+ a.scripts,
434
+ a.mixed_script,
435
+ a.has_confusables,
436
+ a.bidi_conflict,
437
+ a.bidi_control,
438
+ a.has_invisible,
439
+ a.compat_fold,
440
+ a.cross_label_script,
441
+ a.label_scripts,
442
+ a.whole_script_confusable,
443
+ (a.label_whole_script_confusable, a.canonical),
444
+ )
445
+ }
446
+
447
+ // ── Normalization (#375) ──────────────────────────────────────────────────────
448
+
449
+ /// `Disarm._normalize(text, "NFC" | "NFD" | "NFKC" | "NFKD")`. The idiomatic
450
+ /// layer upcases its `form:` symbol/string before forwarding.
451
+ fn normalize(text: Wtf8Text, form: String) -> Result<String, Error> {
452
+ let form: api::NormalizationForm = form.parse().map_err(|e| map_err(&e))?;
453
+ Ok(api::normalize(&text, form))
454
+ }
455
+
456
+ /// `Disarm._normalized?(text, form)`.
457
+ fn is_normalized(text: Wtf8Text, form: String) -> Result<bool, Error> {
458
+ let form: api::NormalizationForm = form.parse().map_err(|e| map_err(&e))?;
459
+ Ok(api::is_normalized(&text, form))
460
+ }
461
+
462
+ // ── Text cleaning (#375) ──────────────────────────────────────────────────────
463
+
464
+ /// `Disarm._collapse_whitespace(text)` — fold whitespace only (#433).
465
+ fn collapse_whitespace(text: Wtf8Text) -> String {
466
+ api::collapse_whitespace(&text)
467
+ }
468
+
469
+ /// `Disarm._strip_control_chars(text)` — remove C0/C1 controls (except tab/newline).
470
+ fn strip_control_chars(text: Wtf8Text) -> String {
471
+ api::strip_control_chars(&text)
472
+ }
473
+
474
+ /// `Disarm._strip_zero_width_chars(text)` — remove ZWSP/ZWNJ/ZWJ/word-joiner.
475
+ fn strip_zero_width_chars(text: Wtf8Text) -> String {
476
+ api::strip_zero_width_chars(&text)
477
+ }
478
+
479
+ /// `Disarm._strip_bidi(text)` — remove Unicode bidirectional control characters.
480
+ fn strip_bidi(text: Wtf8Text) -> String {
481
+ api::strip_bidi(&text)
482
+ }
483
+
484
+ /// `Disarm._strip_tags(text)` — strip the Unicode Tags block, keeping emoji flags (#413).
485
+ fn strip_tags(text: Wtf8Text) -> String {
486
+ api::strip_tags(&text)
487
+ }
488
+
489
+ /// `Disarm._strip_variation_selectors(text)` — strip every variation selector (#413).
490
+ fn strip_variation_selectors(text: Wtf8Text) -> String {
491
+ api::strip_variation_selectors(&text)
492
+ }
493
+
494
+ /// `Disarm._strip_noncharacters(text)` — strip every Unicode noncharacter (#413).
495
+ fn strip_noncharacters(text: Wtf8Text) -> String {
496
+ api::strip_noncharacters(&text)
497
+ }
498
+
499
+ /// `Disarm._strip_pua(text)` — strip every Private Use Area code point (#413).
500
+ fn strip_pua(text: Wtf8Text) -> String {
501
+ api::strip_pua(&text)
502
+ }
503
+
504
+ /// `Disarm._strip_zalgo(text, max_marks)` — cap combining marks per base.
505
+ fn strip_zalgo(text: Wtf8Text, max_marks: usize) -> String {
506
+ api::strip_zalgo(&text, max_marks)
507
+ }
508
+
509
+ /// `Disarm._zalgo?(text, threshold)` — any base carrying > threshold marks.
510
+ fn is_zalgo(text: Wtf8Text, threshold: usize) -> bool {
511
+ api::is_zalgo(&text, threshold)
512
+ }
513
+
514
+ // ── Grapheme clusters (#375) ──────────────────────────────────────────────────
515
+
516
+ /// `Disarm._grapheme_len(text)` — count of user-perceived characters.
517
+ fn grapheme_len(text: Wtf8Text) -> usize {
518
+ api::grapheme_len(&text)
519
+ }
520
+
521
+ /// `Disarm._grapheme_split(text)` — split into grapheme-cluster strings.
522
+ fn grapheme_split(text: Wtf8Text) -> Vec<String> {
523
+ api::grapheme_split(&text)
524
+ }
525
+
526
+ /// `Disarm._grapheme_truncate(text, max_graphemes)` — truncate by graphemes,
527
+ /// never mid-cluster.
528
+ fn grapheme_truncate(text: Wtf8Text, max_graphemes: usize) -> String {
529
+ api::grapheme_truncate(&text, max_graphemes)
530
+ }
531
+
532
+ /// `Disarm._grapheme_width(cluster, ambiguous_wide)` — display columns of one
533
+ /// cluster (East Asian Width).
534
+ fn grapheme_width(cluster: String, ambiguous_wide: bool) -> usize {
535
+ api::grapheme_width(&cluster, ambiguous_wide)
536
+ }
537
+
538
+ /// `Disarm._terminal_width(text, ambiguous_wide)` — display columns of the whole
539
+ /// string.
540
+ fn terminal_width(text: Wtf8Text, ambiguous_wide: bool) -> usize {
541
+ api::terminal_width(&text, ambiguous_wide)
542
+ }
543
+
544
+ // ── Filenames (#375) ──────────────────────────────────────────────────────────
545
+
546
+ /// `Disarm._sanitize_filename(text, separator, max_length, platform, lang,
547
+ /// preserve_extension)` — fallible; `platform` is "universal" | "windows" | "posix".
548
+ #[allow(clippy::too_many_arguments)]
549
+ fn sanitize_filename(
550
+ text: Wtf8Text,
551
+ separator: String,
552
+ max_length: usize,
553
+ platform: String,
554
+ lang: Option<String>,
555
+ preserve_extension: bool,
556
+ ) -> Result<String, Error> {
557
+ let platform: api::Platform = platform.parse().map_err(|e| map_err(&e))?;
558
+ api::sanitize_filename(
559
+ &text,
560
+ &separator,
561
+ max_length,
562
+ platform,
563
+ lang.as_deref(),
564
+ preserve_extension,
565
+ )
566
+ .map_err(|e| map_err(&e))
567
+ }
568
+
569
+ // ── Reverse transliteration & untranslatable scan (#375) ──────────────────────
570
+
571
+ /// `Disarm._reverse_transliterate(text, lang)` — Latin → native; `lang` is
572
+ /// "el" | "ru" | "uk".
573
+ fn reverse_transliterate(text: Wtf8Text, lang: String) -> Result<String, Error> {
574
+ let lang: api::ReverseLang = lang.parse().map_err(|e| map_err(&e))?;
575
+ Ok(api::reverse_transliterate(&text, lang))
576
+ }
577
+
578
+ /// `Disarm._find_untranslatable(text, scheme, lang)` — every character with no
579
+ /// romanization, as `[char, byte_offset]` pairs (the Ruby layer maps these to
580
+ /// `{ char:, offset: }` hashes).
581
+ fn find_untranslatable(
582
+ text: Wtf8Text,
583
+ scheme: String,
584
+ lang: Option<String>,
585
+ ) -> Result<Vec<(String, usize)>, Error> {
586
+ let mut builder = api::Transliterate::new();
587
+ if scheme != "default" {
588
+ let scheme: api::Scheme = scheme.parse().map_err(|e| map_err(&e))?;
589
+ builder = builder.scheme(scheme);
590
+ }
591
+ if let Some(lang) = lang {
592
+ builder = builder.lang(lang);
593
+ }
594
+ Ok(builder
595
+ .find_untranslatable(&text)
596
+ .into_iter()
597
+ .map(|u| (u.ch.to_string(), u.offset))
598
+ .collect())
599
+ }
600
+
601
+ /// `Disarm._ml_normalize(text, lang, emoji_style, fold_case)` — the ML/NLP preset.
602
+ fn ml_normalize(
603
+ text: Wtf8Text,
604
+ lang: Option<String>,
605
+ emoji_style: String,
606
+ fold_case: bool,
607
+ ) -> Result<String, Error> {
608
+ api::ml_normalize(&text, lang.as_deref(), &emoji_style, fold_case)
609
+ .map(std::borrow::Cow::into_owned)
610
+ .map_err(|e| map_err(&e))
611
+ }
612
+
613
+ // ── Script analysis (#375) ────────────────────────────────────────────────────
614
+
615
+ /// `Disarm._detect_scripts(text)` — Unicode scripts present, in first-appearance
616
+ /// order (Common/Inherited excluded).
617
+ fn detect_scripts(text: Wtf8Text) -> Vec<String> {
618
+ api::detect_scripts(&text)
619
+ .into_iter()
620
+ .map(str::to_owned)
621
+ .collect()
622
+ }
623
+
624
+ /// `Disarm._is_mixed_script?(text)` — whether `text` mixes more than one script.
625
+ fn is_mixed_script(text: Wtf8Text) -> bool {
626
+ api::is_mixed_script(&text)
627
+ }
628
+
629
+ /// `Disarm._has_bidi_conflict?(text)` — whether `text` mixes strong left-to-right
630
+ /// and strong right-to-left characters (the "BiDi Swap" reorder precondition,
631
+ /// #412). Fires on the real letters, no `U+202x` override; `false` is not a
632
+ /// safety guarantee.
633
+ fn has_bidi_conflict(text: Wtf8Text) -> bool {
634
+ api::has_bidi_conflict(&text)
635
+ }
636
+
637
+ /// `Disarm._has_bidi_control?(text)` — All twelve UAX #9 explicit formatting characters, uncontexted (#778).
638
+ /// The counterpart to `has_bidi_conflict`, which reads strong-direction letters and is
639
+ /// blind to these; the two are disjoint. The anomaly detector's `bidi` kind reports nine
640
+ /// of the twelve, holding back LRM, RLM and ALM because a lone directional mark is
641
+ /// ordinary in right-to-left text.
642
+ fn has_bidi_control(text: Wtf8Text) -> bool {
643
+ api::has_bidi_control(&text)
644
+ }
645
+
646
+ /// `Disarm._inspect_auto_lang(text)` — `[script, chosen_lang, reason,
647
+ /// discriminators_hit]` (the Ruby layer maps it to a hash). `script`/`chosen_lang`
648
+ /// are nil when nothing was detected.
649
+ fn inspect_auto_lang(text: Wtf8Text) -> (Option<String>, Option<String>, String, Vec<String>) {
650
+ let r = api::inspect_auto_lang(&text);
651
+ (r.script, r.chosen_lang, r.reason, r.discriminators_hit)
652
+ }
653
+
654
+ // ── Metadata introspection (#404 phase 3) ─────────────────────────────────────
655
+
656
+ /// `Disarm._lang_info(code)` — curated metadata for one language as a Ruby Hash
657
+ /// with symbol keys (`{ name:, script:, region:, context: }`), mirroring the
658
+ /// hash-building style of `inspect_auto_lang`'s wrapper. Fails (ArgumentError →
659
+ /// Disarm::InvalidArgument) on an unknown code.
660
+ fn lang_info(code: String) -> Result<RHash, Error> {
661
+ let meta = api::lang_info(&code).map_err(|e| map_err(&e))?;
662
+ // GVL invariant: a Ruby method callback always holds the GVL. Justified.
663
+ #[allow(clippy::expect_used)]
664
+ let ruby = Ruby::get().expect("a Ruby method callback always holds the GVL");
665
+ let hash = ruby.hash_new();
666
+ hash.aset(ruby.to_symbol("name"), meta.name)?;
667
+ hash.aset(ruby.to_symbol("script"), meta.script)?;
668
+ hash.aset(ruby.to_symbol("region"), meta.region)?;
669
+ hash.aset(ruby.to_symbol("context"), meta.context)?;
670
+ Ok(hash)
671
+ }
672
+
673
+ /// `Disarm._script_info(name)` — curated metadata for one script as a Ruby Hash
674
+ /// with symbol keys (`{ name:, default_lang:, example:, context_aware: }`);
675
+ /// `default_lang` is `nil` when the core has none. Fails (ArgumentError →
676
+ /// Disarm::InvalidArgument) on an unknown script.
677
+ fn script_info(name: String) -> Result<RHash, Error> {
678
+ let meta = api::script_info(&name).map_err(|e| map_err(&e))?;
679
+ // GVL invariant: a Ruby method callback always holds the GVL. Justified.
680
+ #[allow(clippy::expect_used)]
681
+ let ruby = Ruby::get().expect("a Ruby method callback always holds the GVL");
682
+ let hash = ruby.hash_new();
683
+ hash.aset(ruby.to_symbol("name"), meta.name)?;
684
+ // `Option<&str>` maps to the string or `nil`, matching the core's `None`.
685
+ hash.aset(ruby.to_symbol("default_lang"), meta.default_lang)?;
686
+ hash.aset(ruby.to_symbol("example"), meta.example)?;
687
+ hash.aset(ruby.to_symbol("context_aware"), meta.context_aware)?;
688
+ Ok(hash)
689
+ }
690
+
691
+ /// The UCD release disarm's normalizer implements (#645). Not a library-wide Unicode
692
+ /// version — the bundled tables track different releases; this is the one integrators ask
693
+ /// about, because it decides whether disarm's normalization agrees with the host
694
+ /// platform's.
695
+ fn unicode_version() -> String {
696
+ api::UNICODE_VERSION.to_owned()
697
+ }
698
+
699
+ /// Whether a key stored under an earlier release still compares equal (#645). A
700
+ /// monotonic counter, not a version: two artifacts reporting the same value produce the
701
+ /// same key for the same input. Meaningless in isolation, by design.
702
+ fn key_schema_version() -> u32 {
703
+ api::KEY_SCHEMA_VERSION
704
+ }
705
+
706
+ /// `Disarm._confusables_version` — the bundled `confusables.txt` release (#560).
707
+ fn confusables_version() -> String {
708
+ api::CONFUSABLES_VERSION.to_owned()
709
+ }
710
+
711
+ /// `Disarm._list_scripts` — every script disarm knows, as stable UCD identifiers.
712
+ fn list_scripts() -> Vec<String> {
713
+ api::list_scripts().into_iter().map(str::to_owned).collect()
714
+ }
715
+
716
+ /// `Disarm._list_context_langs` — language codes with context-aware support.
717
+ fn list_context_langs() -> Vec<String> {
718
+ api::list_context_langs()
719
+ .into_iter()
720
+ .map(str::to_owned)
721
+ .collect()
722
+ }
723
+
724
+ // ── Anomaly detection (#389) ──────────────────────────────────────────────────
725
+
726
+ /// Collect a `Vec<String>` lexicon into a `HashSet<String>` for O(1) membership
727
+ /// lookups. Shared by `has_anomalies` and `inspect_anomalies`.
728
+ fn collect_lexicon(lexicon: Vec<String>) -> HashSet<String> {
729
+ // Delegates to api::lexicon, which lowercases entries so a title-cased wordlist
730
+ // still matches the detector's lowercased decoded words.
731
+ api::lexicon(lexicon)
732
+ }
733
+
734
+ /// A reusable, pre-built lexicon (`Disarm::Lexicon`) — the `HashSet<String>` is
735
+ /// collected once at construction so repeated `has_anomalies?`/`inspect_anomalies`
736
+ /// calls over the same word list skip the per-call Array→HashSet rebuild (HAI-SDLC
737
+ /// 6.1). Mirrors the Python binding's reusable lexicon handle.
738
+ #[magnus::wrap(class = "Disarm::Lexicon", free_immediately, size)]
739
+ struct Lexicon {
740
+ inner: HashSet<String>,
741
+ }
742
+
743
+ /// `Disarm::Lexicon.new(words)` — build the internal `HashSet<String>` once from an
744
+ /// Array/Set of words (reusing `collect_lexicon`).
745
+ fn lexicon_new(words: Vec<String>) -> Lexicon {
746
+ Lexicon {
747
+ inner: collect_lexicon(words),
748
+ }
749
+ }
750
+
751
+ /// `Disarm._has_anomalies?(text, lexicon)` — `lexicon` is an array of common words.
752
+ fn has_anomalies(text: Wtf8Text, lexicon: Vec<String>) -> bool {
753
+ api::has_anomalies(&text, &collect_lexicon(lexicon))
754
+ }
755
+
756
+ /// `Disarm._has_anomalies_lex(text, lexicon)` — the reuse path: takes a pre-built
757
+ /// `Disarm::Lexicon`, so the `HashSet` is shared rather than rebuilt per call.
758
+ fn has_anomalies_lex(text: Wtf8Text, lex: &Lexicon) -> bool {
759
+ api::has_anomalies(&text, &lex.inner)
760
+ }
761
+
762
+ // The flat tuple return is intentional: `lib/disarm.rb` maps it to a named hash.
763
+ // A dedicated magnus struct would require registering a Ruby class just for this
764
+ // internal boundary, which is more boilerplate than the tuple costs.
765
+ #[allow(clippy::type_complexity)]
766
+ type AnomalyReportTuple = (
767
+ bool,
768
+ Vec<String>,
769
+ Vec<(String, String, usize, usize, String, String)>,
770
+ Option<String>,
771
+ );
772
+
773
+ /// Run `api::inspect_anomalies` and flatten its report into the tuple shape the
774
+ /// Ruby layer maps to a hash. Shared by the Array and `Disarm::Lexicon` entrypoints.
775
+ fn inspect_anomalies_impl(text: &str, lex: &HashSet<String>) -> AnomalyReportTuple {
776
+ let r = api::inspect_anomalies(text, lex);
777
+ let findings = r
778
+ .findings
779
+ .into_iter()
780
+ .map(|f| {
781
+ let reason = f.reason();
782
+ (
783
+ f.kind.as_str().to_string(),
784
+ f.token,
785
+ f.start,
786
+ f.end,
787
+ f.detail,
788
+ reason,
789
+ )
790
+ })
791
+ .collect();
792
+ let kinds = r.kinds.iter().map(|k| k.as_str().to_string()).collect();
793
+ (r.anomalous, kinds, findings, r.reason)
794
+ }
795
+
796
+ /// `Disarm._inspect_anomalies(text, lexicon)` — `[anomalous, kinds, findings,
797
+ /// reason]` where each finding is `[kind, token, start, end, detail, reason]` (the
798
+ /// Ruby layer maps it to a hash).
799
+ fn inspect_anomalies(text: Wtf8Text, lexicon: Vec<String>) -> AnomalyReportTuple {
800
+ inspect_anomalies_impl(&text, &collect_lexicon(lexicon))
801
+ }
802
+
803
+ /// `Disarm._inspect_anomalies_lex(text, lexicon)` — the reuse path: the same tuple
804
+ /// shape as `inspect_anomalies`, but taking a pre-built `Disarm::Lexicon`.
805
+ fn inspect_anomalies_lex(text: Wtf8Text, lex: &Lexicon) -> AnomalyReportTuple {
806
+ inspect_anomalies_impl(&text, &lex.inner)
807
+ }
808
+
809
+ // ── Pipeline (#404 phase 2) ───────────────────────────────────────────────────
810
+
811
+ /// A reusable, pre-built policy pipeline (`Disarm::Pipeline`) — the profile's
812
+ /// steps are validated and assembled once at construction (`get_pipeline`), so
813
+ /// repeated `process` calls over the same profile skip the per-call profile
814
+ /// lookup/validation. Mirrors the `Disarm::Lexicon` reusable handle (and the
815
+ /// Python binding's pipeline handle).
816
+ #[magnus::wrap(class = "Disarm::Pipeline", free_immediately, size)]
817
+ struct Pipeline {
818
+ inner: api::Pipeline,
819
+ }
820
+
821
+ /// `Disarm::Pipeline#process(text)` — run the pre-built pipeline over `text`.
822
+ fn pipeline_process(rb_self: &Pipeline, text: Wtf8Text) -> Result<String, Error> {
823
+ rb_self.inner.process(&text).map_err(|e| map_err(&e))
824
+ }
825
+
826
+ /// `Disarm._get_pipeline(profile)` — build a reusable `Disarm::Pipeline` for a
827
+ /// named policy profile. Fails (Disarm::InvalidArgument) on an unknown profile.
828
+ fn get_pipeline(profile: String) -> Result<Pipeline, Error> {
829
+ Ok(Pipeline {
830
+ inner: api::get_pipeline(&profile).map_err(|e| map_err(&e))?,
831
+ })
832
+ }
833
+
834
+ // `name = "disarm"` so the exported init symbol is `Init_disarm` (matching the
835
+ // `disarm.so` the gem loads), independent of the `disarm-ruby` package name.
836
+ #[magnus::init(name = "disarm")]
837
+ fn init(ruby: &Ruby) -> Result<(), Error> {
838
+ let module = ruby.define_module("Disarm")?;
839
+
840
+ // Raw, `_`-prefixed shims wrapped by the idiomatic Ruby layer (#357).
841
+ module.define_singleton_method("_transliterate", function!(transliterate, 1))?;
842
+ module.define_singleton_method("_transliterate_opts", function!(transliterate_opts, 3))?;
843
+ module.define_singleton_method(
844
+ "_normalize_confusables",
845
+ function!(normalize_confusables, 3),
846
+ )?;
847
+ module.define_singleton_method("_confusable?", function!(is_confusable, 2))?;
848
+ module.define_singleton_method("_slugify", function!(slugify, 13))?;
849
+ module.define_singleton_method("_demojize", function!(demojize, 2))?;
850
+ module.define_singleton_method("_canonicalize_strict", function!(canonicalize_strict, 1))?;
851
+ module.define_singleton_method("_strip_format", function!(strip_format, 1))?;
852
+ module.define_singleton_method("_strip_obfuscation", function!(strip_obfuscation, 1))?;
853
+ module.define_singleton_method("_canonicalize", function!(canonicalize, 1))?;
854
+
855
+ // Key-derivation presets (#404 Group A parity backfill).
856
+ module.define_singleton_method("_search_key", function!(search_key, 2))?;
857
+ module.define_singleton_method("_sort_key", function!(sort_key, 2))?;
858
+ module.define_singleton_method("_catalog_key", function!(catalog_key, 3))?;
859
+
860
+ // No options / no symbols, but still wrapped by the Ruby layer so a wrong-type
861
+ // argument surfaces as Disarm::InvalidArgument rather than a raw TypeError —
862
+ // keeping `rescue Disarm::Error` exhaustive across the whole public surface.
863
+ module.define_singleton_method("_strip_accents", function!(strip_accents, 1))?;
864
+ module.define_singleton_method("_fold_case", function!(fold_case, 1))?;
865
+ module.define_singleton_method(
866
+ "_is_case_fold_stable?",
867
+ function!(is_case_fold_stable, 1),
868
+ )?;
869
+ module.define_singleton_method("_find_key_collisions", function!(find_key_collisions, 3))?;
870
+ module.define_singleton_method("_suspicious_hostname?", function!(suspicious_hostname, 1))?;
871
+ module.define_singleton_method("_analyze_hostname", function!(analyze_hostname, 2))?;
872
+
873
+ // Normalization + text-cleaning primitives (#375 parity backfill).
874
+ module.define_singleton_method("_normalize", function!(normalize, 2))?;
875
+ module.define_singleton_method("_normalized?", function!(is_normalized, 2))?;
876
+ module.define_singleton_method("_collapse_whitespace", function!(collapse_whitespace, 1))?;
877
+ module.define_singleton_method("_strip_control_chars", function!(strip_control_chars, 1))?;
878
+ module.define_singleton_method(
879
+ "_strip_zero_width_chars",
880
+ function!(strip_zero_width_chars, 1),
881
+ )?;
882
+ module.define_singleton_method("_strip_bidi", function!(strip_bidi, 1))?;
883
+ module.define_singleton_method("_strip_tags", function!(strip_tags, 1))?;
884
+ module.define_singleton_method(
885
+ "_strip_variation_selectors",
886
+ function!(strip_variation_selectors, 1),
887
+ )?;
888
+ module.define_singleton_method("_strip_noncharacters", function!(strip_noncharacters, 1))?;
889
+ module.define_singleton_method("_strip_pua", function!(strip_pua, 1))?;
890
+ module.define_singleton_method("_strip_zalgo", function!(strip_zalgo, 2))?;
891
+ module.define_singleton_method("_zalgo?", function!(is_zalgo, 2))?;
892
+
893
+ // Grapheme-cluster operations (#375 parity backfill).
894
+ module.define_singleton_method("_grapheme_len", function!(grapheme_len, 1))?;
895
+ module.define_singleton_method("_grapheme_split", function!(grapheme_split, 1))?;
896
+ module.define_singleton_method("_grapheme_truncate", function!(grapheme_truncate, 2))?;
897
+ module.define_singleton_method("_grapheme_width", function!(grapheme_width, 2))?;
898
+ module.define_singleton_method("_terminal_width", function!(terminal_width, 2))?;
899
+
900
+ // Filenames, reverse transliteration, and script analysis (#375).
901
+ module.define_singleton_method("_sanitize_filename", function!(sanitize_filename, 6))?;
902
+ module.define_singleton_method(
903
+ "_reverse_transliterate",
904
+ function!(reverse_transliterate, 2),
905
+ )?;
906
+ module.define_singleton_method("_find_untranslatable", function!(find_untranslatable, 3))?;
907
+ module.define_singleton_method(
908
+ "_unmapped_confusables",
909
+ function!(unmapped_confusables, 1),
910
+ )?;
911
+ module.define_singleton_method(
912
+ "_find_unmapped_confusables",
913
+ function!(find_unmapped_confusables, 2),
914
+ )?;
915
+ module.define_singleton_method("_ml_normalize", function!(ml_normalize, 4))?;
916
+ module.define_singleton_method("_detect_scripts", function!(detect_scripts, 1))?;
917
+ module.define_singleton_method("_is_mixed_script?", function!(is_mixed_script, 1))?;
918
+ module.define_singleton_method("_has_bidi_conflict?", function!(has_bidi_conflict, 1))?;
919
+ module.define_singleton_method("_has_bidi_control?", function!(has_bidi_control, 1))?;
920
+ module.define_singleton_method("_inspect_auto_lang", function!(inspect_auto_lang, 1))?;
921
+
922
+ // Metadata introspection (#404 phase 3 parity backfill).
923
+ module.define_singleton_method("_lang_info", function!(lang_info, 1))?;
924
+ module.define_singleton_method("_script_info", function!(script_info, 1))?;
925
+ module.define_singleton_method(
926
+ "_confusables_version",
927
+ function!(confusables_version, 0),
928
+ )?;
929
+ module.define_singleton_method("_unicode_version", function!(unicode_version, 0))?;
930
+ module.define_singleton_method(
931
+ "_key_schema_version",
932
+ function!(key_schema_version, 0),
933
+ )?;
934
+ module.define_singleton_method("_list_scripts", function!(list_scripts, 0))?;
935
+ module.define_singleton_method("_list_context_langs", function!(list_context_langs, 0))?;
936
+
937
+ module.define_singleton_method("_has_anomalies?", function!(has_anomalies, 2))?;
938
+ module.define_singleton_method("_inspect_anomalies", function!(inspect_anomalies, 2))?;
939
+
940
+ // Reusable lexicon handle (HAI-SDLC 6.1): build the HashSet once, reuse it
941
+ // across calls. `Disarm::Lexicon.new(words)` wraps the Rust `Lexicon`; the
942
+ // `_lex` variants take it directly so the membership set is shared, not rebuilt.
943
+ let lexicon = module.define_class("Lexicon", ruby.class_object())?;
944
+ lexicon.define_singleton_method("new", function!(lexicon_new, 1))?;
945
+ module.define_singleton_method("_has_anomalies_lex", function!(has_anomalies_lex, 2))?;
946
+ module.define_singleton_method(
947
+ "_inspect_anomalies_lex",
948
+ function!(inspect_anomalies_lex, 2),
949
+ )?;
950
+
951
+ // Reusable pipeline handle (#404 phase 2): build the profile's steps once via
952
+ // `Disarm.get_pipeline(profile)` (routed through the `_get_pipeline` shim) and
953
+ // reuse the `Disarm::Pipeline` across calls. `process` is the Rust-defined
954
+ // instance method on the wrapped handle. Mirrors `Disarm::Lexicon` above.
955
+ let pipeline = module.define_class("Pipeline", ruby.class_object())?;
956
+ pipeline.define_method("process", method!(pipeline_process, 1))?;
957
+ module.define_singleton_method("_get_pipeline", function!(get_pipeline, 1))?;
958
+ Ok(())
959
+ }