disarm 0.15.0 → 0.16.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d75406993aaa2ac76a20429c567601447bcffa79708398444b83dc8b445ebf01
4
- data.tar.gz: cfea208f2da86fd373ed82f4e25a9166277ffd1806c22614551418ddb20acb05
3
+ metadata.gz: 0b0ec44307d78ca53394c2b8aa21522f3e2e9a486c83bd536abdb8afd2b0ea7e
4
+ data.tar.gz: e4e5edead5532bc809a8b2c3e4389b02177246d0d5c783bf5b24b641e32bb576
5
5
  SHA512:
6
- metadata.gz: 68a46c759f40421b154e1879500edecf0ced53931224e317b1f3dacfb5a03750ebe132bfe7b1efd7f4be21d384f4478bfc104d826e27675c85b61c79723b40a1
7
- data.tar.gz: 93d71a7e1d5aac2f0a33e26a72e6658ad842a2408b4e744f70ed73bdc1d141b567b2b575ec241c8e738e2c54dc39d4f49309118b353f7985084226d089de7ca3
6
+ metadata.gz: 65a1b31c791a89e407a697af6d89b8f65e772803705e0cb12d3a8b458d4bae093c1dfc558dfa9749db10857dcafe521b221d28979e22f1a37d66326cd2d86fe6
7
+ data.tar.gz: a26f6cdfca9f10d678d5b8b56d243be22ac91a1d582d92a5fb6aee7d0f28b8abbe154da24e2bad1e924c1c5121f6c26baf937cba8f5caebc06cc954810d13458
@@ -30,7 +30,7 @@ crate-type = ["cdylib"]
30
30
  # dep — not a path — so the gem is self-contained and the rb-sys-dock cross-gem
31
31
  # build (which mounts only this gem dir) can fetch it. Imported as `disarm_core`
32
32
  # because the package name `disarm` would otherwise clash with this crate.
33
- disarm_core = { package = "disarm", version = "0.15", default-features = false }
33
+ disarm_core = { package = "disarm", version = "0.16", default-features = false }
34
34
  # magnus: ergonomic, safe Ruby<->Rust bindings over rb-sys. 0.8 supports Ruby >= 3.0
35
35
  # (the gem's required_ruby_version); rb-sys handles the platform glue.
36
36
  magnus = "0.8"
@@ -215,6 +215,16 @@ fn normalize_confusables(
215
215
  Ok(api::normalize_confusables_with(&text, target, digit_policy).into_owned())
216
216
  }
217
217
 
218
+ /// The `digit_policy` token every key builder takes (#896): parsed once, at the boundary.
219
+ fn parse_policy(digit_policy: &str) -> Result<api::DigitPolicy, Error> {
220
+ digit_policy.parse().map_err(|e| map_err(&e))
221
+ }
222
+
223
+ /// `Disarm._is_canonical?(text, preset)` — already its own canonical form (#730).
224
+ fn is_canonical(text: Wtf8Text, preset: String) -> Result<bool, Error> {
225
+ api::is_canonical(&text, &preset).map_err(|e| map_err(&e))
226
+ }
227
+
218
228
  /// `Disarm._confusable?(text, "latin" | "cyrillic" | "arabic" | "hebrew")`.
219
229
  fn is_confusable(text: Wtf8Text, target: String) -> Result<bool, Error> {
220
230
  let target: api::TargetScript = target.parse().map_err(|e| map_err(&e))?;
@@ -321,6 +331,11 @@ fn slugify(
321
331
  api::slugify(&text, &config)
322
332
  }
323
333
 
334
+ /// `Disarm._replace_emoji(text, replacement)` — every emoji replaced, verbatim (#972).
335
+ fn replace_emoji(text: Wtf8Text, replacement: String) -> String {
336
+ api::replace_emoji(&text, &replacement)
337
+ }
338
+
324
339
  /// `Disarm._demojize(text, strip_modifiers)`.
325
340
  fn demojize(text: Wtf8Text, strip_modifiers: bool) -> String {
326
341
  api::demojize(&text, strip_modifiers)
@@ -340,8 +355,8 @@ fn demojize(text: Wtf8Text, strip_modifiers: bool) -> String {
340
355
  ///
341
356
  /// Unlike `canonicalize` it does NOT fold confusables, so non-Latin text keeps its script
342
357
  /// — the point of the preset.
343
- fn canonicalize_strict(text: Wtf8Text) -> Result<String, Error> {
344
- api::canonicalize_strict(&text)
358
+ fn canonicalize_strict(text: Wtf8Text, digit_policy: String) -> Result<String, Error> {
359
+ api::canonicalize_strict_with(&text, parse_policy(&digit_policy)?)
345
360
  .map(std::borrow::Cow::into_owned)
346
361
  .map_err(|e| map_err(&e))
347
362
  }
@@ -350,42 +365,72 @@ fn strip_format(text: Wtf8Text) -> String {
350
365
  api::strip_format(&text).into_owned()
351
366
  }
352
367
 
353
- fn strip_obfuscation(text: Wtf8Text) -> Result<String, Error> {
354
- api::strip_obfuscation(&text)
368
+ fn strip_obfuscation(text: Wtf8Text, digit_policy: String) -> Result<String, Error> {
369
+ api::strip_obfuscation_with(&text, parse_policy(&digit_policy)?)
355
370
  .map(std::borrow::Cow::into_owned)
356
371
  .map_err(|e| map_err(&e))
357
372
  }
358
373
 
359
- fn canonicalize(text: Wtf8Text) -> Result<String, Error> {
360
- api::canonicalize(&text)
374
+ fn canonicalize(text: Wtf8Text, digit_policy: String) -> Result<String, Error> {
375
+ api::canonicalize_with(&text, parse_policy(&digit_policy)?)
361
376
  .map(std::borrow::Cow::into_owned)
362
377
  .map_err(|e| map_err(&e))
363
378
  }
364
379
 
365
380
  /// `Disarm._search_key(text, lang)` — case/accent/script-insensitive lookup key.
366
381
  /// `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())
382
+ fn search_key(text: Wtf8Text, lang: Option<String>, digit_policy: String) -> Result<String, Error> {
383
+ api::search_key_with(&text, lang.as_deref(), parse_policy(&digit_policy)?)
369
384
  .map(std::borrow::Cow::into_owned)
370
385
  .map_err(|e| map_err(&e))
371
386
  }
372
387
 
373
388
  /// `Disarm._sort_key(text, lang)` — collation sort key (preserves base accented
374
389
  /// 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())
390
+ fn sort_key(text: Wtf8Text, lang: Option<String>, digit_policy: String) -> Result<String, Error> {
391
+ api::sort_key_with(&text, lang.as_deref(), parse_policy(&digit_policy)?)
377
392
  .map(std::borrow::Cow::into_owned)
378
393
  .map_err(|e| map_err(&e))
379
394
  }
380
395
 
381
396
  /// `Disarm._catalog_key(text, lang, strict_iso9)` — catalog deduplication key.
382
397
  /// `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)
398
+ fn catalog_key(
399
+ text: Wtf8Text,
400
+ lang: Option<String>,
401
+ strict_iso9: bool,
402
+ digit_policy: String,
403
+ ) -> Result<String, Error> {
404
+ api::catalog_key_with(&text, lang.as_deref(), strict_iso9, parse_policy(&digit_policy)?)
405
+ .map(std::borrow::Cow::into_owned)
406
+ .map_err(|e| map_err(&e))
407
+ }
408
+
409
+ /// `Disarm._skeleton_key(text, digit_policy)` — the TR39 identifier skeleton plus the two
410
+ /// prototype classes disarm's table keeps apart (#650). A spoof key: never for display.
411
+ fn skeleton_key(text: Wtf8Text, digit_policy: String) -> Result<String, Error> {
412
+ api::skeleton_key(&text, parse_policy(&digit_policy)?)
385
413
  .map(std::borrow::Cow::into_owned)
386
414
  .map_err(|e| map_err(&e))
387
415
  }
388
416
 
417
+ /// `Disarm._edit_distance(a, b)` — Levenshtein distance in characters (#894).
418
+ fn edit_distance(a: Wtf8Text, b: Wtf8Text) -> usize {
419
+ api::edit_distance(&a, &b)
420
+ }
421
+
422
+ /// `Disarm._nearest_match(value, candidates, max_distance)` — the closest candidate and
423
+ /// its distance as a `(value, distance)` tuple, or `nil` beyond `max_distance` (#894).
424
+ /// The Ruby layer maps the tuple to a `{ value:, distance: }` hash.
425
+ fn nearest_match(
426
+ value: Wtf8Text,
427
+ candidates: Vec<String>,
428
+ max_distance: usize,
429
+ ) -> Option<(String, usize)> {
430
+ api::nearest_match(&value, candidates.iter().map(String::as_str), max_distance)
431
+ .map(|m| (m.value, m.distance))
432
+ }
433
+
389
434
  /// `Disarm._suspicious_hostname?(host)` — flags mixed-script / confusable IDN
390
435
  /// spoofs. A false result asserts nothing was *found*, not that the host is safe.
391
436
  fn suspicious_hostname(host: Wtf8Text) -> bool {
@@ -670,6 +715,22 @@ fn lang_info(code: String) -> Result<RHash, Error> {
670
715
  Ok(hash)
671
716
  }
672
717
 
718
+ /// `Disarm._confusable_coverage(script)` — TR39 sources whose prototype is in `script`
719
+ /// and how many the bundled tables fold (#963), as a Ruby Hash with symbol keys
720
+ /// (`{ script:, sources:, folded: }`). The denominator `unmapped_confusables` does not
721
+ /// have. Fails (ArgumentError → Disarm::InvalidArgument) on an unknown script.
722
+ fn confusable_coverage(script: String) -> Result<RHash, Error> {
723
+ let row = api::confusable_coverage(&script).map_err(|e| map_err(&e))?;
724
+ // GVL invariant: a Ruby method callback always holds the GVL. Justified.
725
+ #[allow(clippy::expect_used)]
726
+ let ruby = Ruby::get().expect("a Ruby method callback always holds the GVL");
727
+ let hash = ruby.hash_new();
728
+ hash.aset(ruby.to_symbol("script"), row.script)?;
729
+ hash.aset(ruby.to_symbol("sources"), row.sources)?;
730
+ hash.aset(ruby.to_symbol("folded"), row.folded)?;
731
+ Ok(hash)
732
+ }
733
+
673
734
  /// `Disarm._script_info(name)` — curated metadata for one script as a Ruby Hash
674
735
  /// with symbol keys (`{ name:, default_lang:, example:, context_aware: }`);
675
736
  /// `default_lang` is `nil` when the core has none. Fails (ArgumentError →
@@ -823,6 +884,26 @@ fn pipeline_process(rb_self: &Pipeline, text: Wtf8Text) -> Result<String, Error>
823
884
  rb_self.inner.process(&text).map_err(|e| map_err(&e))
824
885
  }
825
886
 
887
+ /// `Disarm::Pipeline#purpose` — what the named profile is for, or `nil` (#860).
888
+ fn pipeline_purpose(rb_self: &Pipeline) -> Option<String> {
889
+ rb_self.inner.purpose().map(str::to_owned)
890
+ }
891
+
892
+ /// `Disarm::Pipeline#_with_digit_policy(policy)` — a copy of this pipeline whose confusable
893
+ /// passes fold under `policy` (#646). Fails when the profile has no confusables step and the
894
+ /// policy is not the default: a setting that would never run is refused rather than kept.
895
+ /// The Ruby layer wraps it as `#with_digit_policy` so the error arrives as
896
+ /// `Disarm::InvalidArgument`, the way every module-level call does.
897
+ fn pipeline_with_digit_policy(rb_self: &Pipeline, digit_policy: String) -> Result<Pipeline, Error> {
898
+ Ok(Pipeline {
899
+ inner: rb_self
900
+ .inner
901
+ .clone()
902
+ .with_digit_policy(parse_policy(&digit_policy)?)
903
+ .map_err(|e| map_err(&e))?,
904
+ })
905
+ }
906
+
826
907
  /// `Disarm._get_pipeline(profile)` — build a reusable `Disarm::Pipeline` for a
827
908
  /// named policy profile. Fails (Disarm::InvalidArgument) on an unknown profile.
828
909
  fn get_pipeline(profile: String) -> Result<Pipeline, Error> {
@@ -847,15 +928,20 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
847
928
  module.define_singleton_method("_confusable?", function!(is_confusable, 2))?;
848
929
  module.define_singleton_method("_slugify", function!(slugify, 13))?;
849
930
  module.define_singleton_method("_demojize", function!(demojize, 2))?;
850
- module.define_singleton_method("_canonicalize_strict", function!(canonicalize_strict, 1))?;
931
+ module.define_singleton_method("_replace_emoji", function!(replace_emoji, 2))?;
932
+ module.define_singleton_method("_canonicalize_strict", function!(canonicalize_strict, 2))?;
851
933
  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))?;
934
+ module.define_singleton_method("_strip_obfuscation", function!(strip_obfuscation, 2))?;
935
+ module.define_singleton_method("_canonicalize", function!(canonicalize, 2))?;
936
+ module.define_singleton_method("_is_canonical?", function!(is_canonical, 2))?;
854
937
 
855
938
  // 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))?;
939
+ module.define_singleton_method("_search_key", function!(search_key, 3))?;
940
+ module.define_singleton_method("_sort_key", function!(sort_key, 3))?;
941
+ module.define_singleton_method("_catalog_key", function!(catalog_key, 4))?;
942
+ module.define_singleton_method("_skeleton_key", function!(skeleton_key, 2))?;
943
+ module.define_singleton_method("_edit_distance", function!(edit_distance, 2))?;
944
+ module.define_singleton_method("_nearest_match", function!(nearest_match, 3))?;
859
945
 
860
946
  // No options / no symbols, but still wrapped by the Ruby layer so a wrong-type
861
947
  // argument surfaces as Disarm::InvalidArgument rather than a raw TypeError —
@@ -922,6 +1008,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
922
1008
  // Metadata introspection (#404 phase 3 parity backfill).
923
1009
  module.define_singleton_method("_lang_info", function!(lang_info, 1))?;
924
1010
  module.define_singleton_method("_script_info", function!(script_info, 1))?;
1011
+ module.define_singleton_method(
1012
+ "_confusable_coverage",
1013
+ function!(confusable_coverage, 1),
1014
+ )?;
925
1015
  module.define_singleton_method(
926
1016
  "_confusables_version",
927
1017
  function!(confusables_version, 0),
@@ -954,6 +1044,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
954
1044
  // instance method on the wrapped handle. Mirrors `Disarm::Lexicon` above.
955
1045
  let pipeline = module.define_class("Pipeline", ruby.class_object())?;
956
1046
  pipeline.define_method("process", method!(pipeline_process, 1))?;
1047
+ pipeline.define_method("purpose", method!(pipeline_purpose, 0))?;
1048
+ pipeline.define_method("_with_digit_policy", method!(pipeline_with_digit_policy, 1))?;
957
1049
  module.define_singleton_method("_get_pipeline", function!(get_pipeline, 1))?;
958
1050
  Ok(())
959
1051
  }
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Disarm
4
4
  # Kept in lockstep with the Rust crate / Python package version.
5
- VERSION = "0.15.0"
5
+ VERSION = "0.16.0"
6
6
  end
data/lib/disarm.rb CHANGED
@@ -116,11 +116,26 @@ module Disarm
116
116
  translate_errors { _demojize(text, strip_modifiers) }
117
117
  end
118
118
 
119
+ # Replace every emoji with +replacement+, verbatim.
120
+ #
121
+ # The counterpart to +demojize+, and a different question of a different table.
122
+ # +demojize+ asks what CLDR calls a character, so its domain is the name table, which
123
+ # is wider than the emoji: <tt>demojize("x\u2122y")</tt> is "x trade mark y". This
124
+ # asks whether the UCD calls it an emoji — Emoji_Presentation=Yes, an Emoji=Yes base
125
+ # carrying U+FE0F, and the ZWJ, modifier, keycap and flag sequences on those. Nothing
126
+ # else moves.
127
+ #
128
+ # +replacement+ is inserted exactly as given: "" closes an intra-word split and " "
129
+ # keeps two words apart, and no rule serves both.
130
+ def replace_emoji(text, replacement = "")
131
+ translate_errors { _replace_emoji(text, replacement) }
132
+ end
133
+
119
134
  # Canonicalize, but raise rather than silently normalize a structural difference
120
135
  # away — the half of the pair that lets a caller reject input instead of comparing
121
136
  # a value the sender never wrote.
122
- def canonicalize_strict(text)
123
- translate_errors { _canonicalize_strict(text) }
137
+ def canonicalize_strict(text, digit_policy: :numeric)
138
+ translate_errors { _canonicalize_strict(text, digit_policy.to_s) }
124
139
  end
125
140
 
126
141
  # Strip the non-interchange and invisible classes while KEEPING the script.
@@ -136,8 +151,8 @@ module Disarm
136
151
 
137
152
  # Remove obfuscation (zero-width, bidi, combining-mark abuse) while keeping
138
153
  # legible content.
139
- def strip_obfuscation(text)
140
- translate_errors { _strip_obfuscation(text) }
154
+ def strip_obfuscation(text, digit_policy: :numeric)
155
+ translate_errors { _strip_obfuscation(text, digit_policy.to_s) }
141
156
  end
142
157
 
143
158
  # Canonicalize text for security-sensitive comparison: strip obfuscation,
@@ -151,8 +166,8 @@ module Disarm
151
166
  # delimiter can leave here carrying one. #inspect_anomalies reports it as :confusable
152
167
  # WHEN the word also carries an ASCII letter, which is the gate that keeps ordinary
153
168
  # non-Latin text from firing; a delimiter-only string is not reported.
154
- def canonicalize(text)
155
- translate_errors { _canonicalize(text) }
169
+ def canonicalize(text, digit_policy: :numeric)
170
+ translate_errors { _canonicalize(text, digit_policy.to_s) }
156
171
  end
157
172
 
158
173
  # @deprecated Renamed to {#canonicalize} in 0.11 (the +_clean+ name
@@ -165,22 +180,60 @@ module Disarm
165
180
  # Case/accent/script-insensitive search lookup key. `lang:` applies a
166
181
  # language profile for transliteration (e.g. "ru", "uk"); nil means none.
167
182
  # Raises Disarm::InvalidArgument on an unknown lang.
168
- def search_key(text, lang: nil)
169
- translate_errors { _search_key(text, lang&.to_s) }
183
+ def search_key(text, lang: nil, digit_policy: :numeric)
184
+ translate_errors { _search_key(text, lang&.to_s, digit_policy.to_s) }
170
185
  end
171
186
 
172
187
  # Collation sort key (like #search_key, but keeps base accented characters
173
188
  # for correct ordering). `lang:` applies a language profile; nil means none.
174
189
  # Raises Disarm::InvalidArgument on an unknown lang.
175
- def sort_key(text, lang: nil)
176
- translate_errors { _sort_key(text, lang&.to_s) }
190
+ def sort_key(text, lang: nil, digit_policy: :numeric)
191
+ translate_errors { _sort_key(text, lang&.to_s, digit_policy.to_s) }
177
192
  end
178
193
 
179
194
  # Library catalog deduplication key (search_key plus confusable folding).
180
195
  # `lang:` applies a language profile; `strict_iso9:` selects the ISO 9:1995
181
196
  # Cyrillic scheme. Raises Disarm::InvalidArgument on an unknown lang.
182
- def catalog_key(text, lang: nil, strict_iso9: false)
183
- translate_errors { _catalog_key(text, lang&.to_s, strict_iso9) }
197
+ def catalog_key(text, lang: nil, strict_iso9: false, digit_policy: :numeric)
198
+ translate_errors { _catalog_key(text, lang&.to_s, strict_iso9, digit_policy.to_s) }
199
+ end
200
+
201
+ # The TR39 identifier skeleton plus the two prototype classes disarm keeps apart
202
+ # (#650). A spoof key: its only job is to make confusable identifiers collide, and its
203
+ # output is never for display. `digit_policy:` is `:numeric` (the letter half only),
204
+ # `:tr39` (adds `1 ≡ l` and `0 ≡ O`) or `:preserve` (a non-Latin numeral keeps its
205
+ # script).
206
+ def skeleton_key(text, digit_policy: :numeric)
207
+ translate_errors { _skeleton_key(text, digit_policy.to_s) }
208
+ end
209
+
210
+ # Levenshtein edit distance between `a` and `b`, in characters (#894). The one class
211
+ # of registry spoofing the confusable tables deliberately do not model — `paypa1`,
212
+ # `adm1n`. Canonicalize both sides first when composed and decomposed spellings
213
+ # should compare equal.
214
+ def edit_distance(left, right)
215
+ translate_errors { _edit_distance(left, right) }
216
+ end
217
+
218
+ # The candidate closest to `value`, as `{ value:, distance: }`, or nil beyond
219
+ # `max_distance:` (#894). Reports; it does not decide. An exact match is reported with
220
+ # distance 0, and ties go to the first candidate at the lowest distance.
221
+ def nearest_match(value, candidates, max_distance: 1)
222
+ hit = translate_errors { _nearest_match(value, candidates.map(&:to_s), max_distance) }
223
+ hit && { value: hit[0], distance: hit[1] }
224
+ end
225
+
226
+ # Whether `text` is already its own canonical form under `preset:` (#730).
227
+ #
228
+ # The verification-path counterpart to the presets: text in, normalized text out is
229
+ # the generation path, and this is the question a caller asks about bytes that arrive
230
+ # already bound. `has_anomalies?` is not this predicate — 142,760 assigned code points are
231
+ # reported clean by the detector and are not their own canonical form.
232
+ #
233
+ # `preset:` is any name in the preset registry or any profile. Raises
234
+ # Disarm::InvalidArgument on an unknown one.
235
+ def canonical?(text, preset: "canonicalize")
236
+ translate_errors { _is_canonical?(text, preset.to_s) }
184
237
  end
185
238
 
186
239
  # Strip diacritics ("café" → "cafe").
@@ -486,6 +539,22 @@ module Disarm
486
539
  translate_errors { _script_info(name.to_s) }
487
540
  end
488
541
 
542
+ # TR39 confusable sources whose prototype is in +script+, and how many of those
543
+ # disarm's bundled tables fold. Returns a Hash with +:script+, +:sources+ and
544
+ # +:folded+ keys.
545
+ #
546
+ # The denominator +unmapped_confusables+ does not have: that measures one bundled
547
+ # table against the whole 6,565-source population, so a script disarm ships no table
548
+ # for reports a number determined by that absence rather than by its coverage.
549
+ #
550
+ # +:folded+ counts sources any bundled table reaches, not sources folded *toward*
551
+ # this script — Greek is 71 of 159, because the Latin table folds Greek letters that
552
+ # look Latin. A script disarm knows that TR39 never uses as a prototype returns 0 of
553
+ # 0. Raises Disarm::InvalidArgument on an unknown script.
554
+ def confusable_coverage(script)
555
+ translate_errors { _confusable_coverage(script.to_s) }
556
+ end
557
+
489
558
  # The Unicode `confusables.txt` release the bundled confusable tables were folded
490
559
  # from, e.g. "17.0.0". Not a Unicode version for the library as a whole — the
491
560
  # case-folding and width tables track different releases (see docs/provenance.md).
@@ -574,7 +643,9 @@ module Disarm
574
643
  # pipe.process("Café") # => "cafe"
575
644
  # pipe.process("Köln") # reuse the same handle
576
645
  #
577
- # Disarm::Pipeline#process is the Rust-defined instance method on the handle.
646
+ # Disarm::Pipeline#process is the Rust-defined instance method on the handle, and
647
+ # Disarm::Pipeline#with_digit_policy(policy) returns a copy whose confusable passes
648
+ # fold under `policy` (#646); a profile with no confusables step refuses one.
578
649
  def get_pipeline(profile)
579
650
  translate_errors { _get_pipeline(profile.to_s) }
580
651
  end
@@ -615,4 +686,17 @@ module Disarm
615
686
  raise Error, e.message, e.backtrace
616
687
  end
617
688
  end
689
+
690
+ # The reusable handle `Disarm.get_pipeline` returns. `#process` is Rust-defined; this
691
+ # reopens the class for the one method that can fail, so its error arrives as
692
+ # `Disarm::InvalidArgument` the way every module-level call's does.
693
+ class Pipeline
694
+ # A copy of this pipeline whose confusable passes fold under `policy` (#646):
695
+ # `:numeric`, `:tr39` or `:preserve`. Raises Disarm::InvalidArgument when the profile
696
+ # has no confusables step and the policy is not the default — a setting that would
697
+ # never run is refused rather than kept.
698
+ def with_digit_policy(policy)
699
+ Disarm.send(:translate_errors) { _with_digit_policy(policy.to_s) }
700
+ end
701
+ end
618
702
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: disarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Quinn
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys