roo-oxide 0.0.2

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,684 @@
1
+ //! Reads `sharedStrings.xml` into the two arrays roo 4.0.0's
2
+ //! `Roo::Excelx::SharedStrings` builds from it: `#to_a`, the plain text of
3
+ //! each `/sst/si`, and `#to_html`, the same text with rich-text runs wrapped
4
+ //! in HTML tags.
5
+ //!
6
+ //! roo reads the file with Nokogiri and removes its namespaces, so elements
7
+ //! match by local name. [`SheetReader`] first checks that Nokogiri reads the
8
+ //! file as quick-xml does; anything it rejects is left to roo.
9
+
10
+ use std::cell::RefCell;
11
+ use std::fs::File;
12
+ use std::io::{BufRead, BufReader};
13
+ use std::panic::resume_unwind;
14
+ use std::path::Path;
15
+ use std::sync::atomic::{AtomicBool, Ordering};
16
+
17
+ use magnus::gc::Marker;
18
+ use magnus::value::{Opaque, ReprValue};
19
+ use magnus::{prelude::*, DataTypeFunctions, Error, RArray, RString, Ruby, TypedData, Value};
20
+ use quick_xml::events::{BytesRef, BytesStart, Event};
21
+ use quick_xml::XmlVersion;
22
+
23
+ use crate::reader::{Mode, SheetReader, Unsupported};
24
+ use crate::{unsupported, without_gvl};
25
+
26
+ type Result<T> = std::result::Result<T, Unsupported>;
27
+
28
+ /// The text `SharedStrings#fix_invalid_shared_strings` replaces with a line
29
+ /// feed in the document behind `#to_a`, but not in the one behind `#to_html`.
30
+ const CARRIAGE_RETURN: &str = "_x000D_";
31
+
32
+ /// A list of strings kept in one buffer.
33
+ #[derive(Default)]
34
+ struct Strings {
35
+ text: String,
36
+ ends: Vec<u32>,
37
+ }
38
+
39
+ impl Strings {
40
+ fn push(&mut self, string: &str) -> Result<()> {
41
+ let end = string_end(self.text.len(), string.len())?;
42
+ self.text.push_str(string);
43
+ self.ends.push(end);
44
+ Ok(())
45
+ }
46
+
47
+ fn len(&self) -> usize {
48
+ self.ends.len()
49
+ }
50
+
51
+ fn iter(&self) -> impl Iterator<Item = &str> {
52
+ let starts = std::iter::once(0).chain(self.ends.iter().copied());
53
+ starts
54
+ .zip(self.ends.iter().copied())
55
+ .map(|(start, end)| &self.text[start as usize..end as usize])
56
+ }
57
+
58
+ fn get(&self, index: usize) -> &str {
59
+ let start = index.checked_sub(1).map_or(0, |i| self.ends[i]);
60
+ &self.text[start as usize..self.ends[index] as usize]
61
+ }
62
+
63
+ fn into_array(self, ruby: &Ruby) -> std::result::Result<RArray, Error> {
64
+ let out = ruby.ary_new_capa(self.len());
65
+ for string in self.iter() {
66
+ out.push(ruby.str_new(string))?;
67
+ }
68
+ Ok(out)
69
+ }
70
+ }
71
+
72
+ fn string_end(start: usize, len: usize) -> Result<u32> {
73
+ start
74
+ .checked_add(len)
75
+ .and_then(|end| u32::try_from(end).ok())
76
+ .ok_or_else(|| Unsupported("shared strings exceed 32-bit offsets".into()))
77
+ }
78
+
79
+ /// `string[/<([biu]|sup|sub)>/]`, the test in `SharedStrings#use_html?`.
80
+ pub(crate) fn has_html_tag(string: &[u8]) -> bool {
81
+ string.windows(3).enumerate().any(|(i, w)| {
82
+ w[0] == b'<'
83
+ && (matches!(w, [_, b'b' | b'i' | b'u', b'>'])
84
+ || string[i..].starts_with(b"<sup>")
85
+ || string[i..].starts_with(b"<sub>"))
86
+ })
87
+ }
88
+
89
+ pub(crate) enum HtmlSelection {
90
+ Plain,
91
+ Html(Value),
92
+ Invalid,
93
+ }
94
+
95
+ #[derive(TypedData)]
96
+ #[magnus(
97
+ class = "Roo::Oxide::Native::HtmlStrings",
98
+ mark,
99
+ free_immediately,
100
+ size
101
+ )]
102
+ pub(crate) struct HtmlStrings {
103
+ strings: RefCell<Option<Strings>>,
104
+ cached: Opaque<RArray>,
105
+ }
106
+
107
+ impl DataTypeFunctions for HtmlStrings {
108
+ fn mark(&self, marker: &Marker) {
109
+ marker.mark(self.cached);
110
+ }
111
+
112
+ fn size(&self) -> usize {
113
+ size_of::<Self>()
114
+ + self.strings.borrow().as_ref().map_or(0, |strings| {
115
+ strings.text.capacity() + strings.ends.capacity() * size_of::<u32>()
116
+ })
117
+ }
118
+ }
119
+
120
+ impl HtmlStrings {
121
+ pub(crate) fn is_plain(&self, len: usize) -> bool {
122
+ self.strings.borrow().as_ref().is_some_and(|strings| {
123
+ strings.len() >= len
124
+ && strings
125
+ .iter()
126
+ .all(|string| !has_html_tag(string.as_bytes()))
127
+ })
128
+ }
129
+
130
+ pub(crate) fn select(
131
+ &self,
132
+ ruby: &Ruby,
133
+ index: usize,
134
+ ) -> std::result::Result<HtmlSelection, Error> {
135
+ let cached = ruby.get_inner(self.cached);
136
+ if index < cached.len() {
137
+ let value: Value = cached.entry(index as isize)?;
138
+ if !value.is_nil() || self.strings.borrow().is_none() {
139
+ return Ok(match RString::from_value(value) {
140
+ Some(string) if has_html_tag(unsafe { string.as_slice() }) => {
141
+ HtmlSelection::Html(value)
142
+ }
143
+ Some(_) => HtmlSelection::Plain,
144
+ None => HtmlSelection::Invalid,
145
+ });
146
+ }
147
+ }
148
+ {
149
+ let strings = self.strings.borrow();
150
+ let Some(strings) = strings.as_ref().filter(|strings| index < strings.len()) else {
151
+ return Ok(HtmlSelection::Invalid);
152
+ };
153
+ if !has_html_tag(strings.get(index).as_bytes()) {
154
+ return Ok(HtmlSelection::Plain);
155
+ }
156
+ }
157
+ self.materialize(ruby, index).map(HtmlSelection::Html)
158
+ }
159
+
160
+ fn materialize(&self, ruby: &Ruby, index: usize) -> std::result::Result<Value, Error> {
161
+ let cached = ruby.get_inner(self.cached);
162
+ let value: Value = cached.entry(index as isize)?;
163
+ if !value.is_nil() {
164
+ return Ok(value);
165
+ }
166
+ let html = {
167
+ let strings = self.strings.borrow();
168
+ format!(
169
+ "<html>{}</html>",
170
+ strings.as_ref().expect("not materialized").get(index)
171
+ )
172
+ };
173
+ let string = ruby.str_new(&html);
174
+ cached.store(index as isize, string)?;
175
+ Ok(string.as_value())
176
+ }
177
+
178
+ fn to_a(ruby: &Ruby, rb_self: &Self) -> std::result::Result<RArray, Error> {
179
+ let len = rb_self.strings.borrow().as_ref().map(Strings::len);
180
+ if let Some(len) = len {
181
+ for index in 0..len {
182
+ rb_self.materialize(ruby, index)?;
183
+ }
184
+ rb_self.strings.borrow_mut().take();
185
+ }
186
+ Ok(ruby.get_inner(rb_self.cached))
187
+ }
188
+ }
189
+
190
+ /// HTML tags of a rich-text run, in the order roo nests them.
191
+ #[derive(Clone, Copy, Default)]
192
+ struct Format {
193
+ sub: bool,
194
+ sup: bool,
195
+ b: bool,
196
+ i: bool,
197
+ u: bool,
198
+ }
199
+
200
+ impl Format {
201
+ fn tags(&self) -> impl DoubleEndedIterator<Item = &'static str> {
202
+ [
203
+ (self.sub, "sub"),
204
+ (self.sup, "sup"),
205
+ (self.b, "b"),
206
+ (self.i, "i"),
207
+ (self.u, "u"),
208
+ ]
209
+ .into_iter()
210
+ .filter_map(|(on, tag)| on.then_some(tag))
211
+ }
212
+
213
+ /// `SharedStrings#create_html`.
214
+ fn wrap(&self, text: &str, out: &mut String) {
215
+ for tag in self.tags() {
216
+ out.push('<');
217
+ out.push_str(tag);
218
+ out.push('>');
219
+ }
220
+ out.push_str(text);
221
+ for tag in self.tags().rev() {
222
+ out.push_str("</");
223
+ out.push_str(tag);
224
+ out.push('>');
225
+ }
226
+ }
227
+ }
228
+
229
+ struct SharedStringsReader<R: BufRead> {
230
+ xml: quick_xml::Reader<R>,
231
+ buf: Vec<u8>,
232
+ /// Whether to build `#to_html` too.
233
+ html: bool,
234
+ depth: usize,
235
+ in_sst: bool,
236
+ plain: Strings,
237
+ html_strings: Strings,
238
+ item_plain: String,
239
+ item_html: String,
240
+ /// The content of the `t` being read.
241
+ content: String,
242
+ /// Where each text or CDATA node of `content` after the first starts:
243
+ /// Nokogiri keeps text and CDATA in separate nodes.
244
+ node_starts: Vec<usize>,
245
+ }
246
+
247
+ impl SharedStringsReader<BufReader<File>> {
248
+ fn open(path: &Path, html: bool) -> Result<Self> {
249
+ let file = File::open(path).map_err(|e| Unsupported(e.to_string()))?;
250
+ Ok(Self::new(BufReader::with_capacity(64 * 1024, file), html))
251
+ }
252
+ }
253
+
254
+ #[derive(Clone, Copy, PartialEq, Eq)]
255
+ enum Name {
256
+ T,
257
+ R,
258
+ RPr,
259
+ B,
260
+ I,
261
+ U,
262
+ VertAlign,
263
+ Other,
264
+ }
265
+
266
+ fn name(e: &BytesStart<'_>) -> Name {
267
+ match e.local_name().as_ref() {
268
+ "t" => Name::T,
269
+ "r" => Name::R,
270
+ "rPr" => Name::RPr,
271
+ "b" => Name::B,
272
+ "i" => Name::I,
273
+ "u" => Name::U,
274
+ "vertAlign" => Name::VertAlign,
275
+ _ => Name::Other,
276
+ }
277
+ }
278
+
279
+ impl<R: BufRead> SharedStringsReader<R> {
280
+ fn new(input: R, html: bool) -> Self {
281
+ Self {
282
+ xml: quick_xml::Reader::from_reader(input),
283
+ buf: Vec::with_capacity(16 * 1024),
284
+ html,
285
+ depth: 0,
286
+ in_sst: false,
287
+ plain: Strings::default(),
288
+ html_strings: Strings::default(),
289
+ item_plain: String::new(),
290
+ item_html: String::new(),
291
+ content: String::new(),
292
+ node_starts: Vec::new(),
293
+ }
294
+ }
295
+
296
+ /// Reads up to the end of the next `/sst/si`. `false` at the end of the
297
+ /// document.
298
+ fn next_item(&mut self) -> Result<bool> {
299
+ let mut buf = std::mem::take(&mut self.buf);
300
+ let read = self.find_item(&mut buf);
301
+ self.buf = buf;
302
+ read
303
+ }
304
+
305
+ fn find_item(&mut self, buf: &mut Vec<u8>) -> Result<bool> {
306
+ loop {
307
+ buf.clear();
308
+ match self.read_event(buf)? {
309
+ Event::Start(e) => {
310
+ self.depth += 1;
311
+ match self.depth {
312
+ 1 => self.in_sst = e.local_name().as_ref() == "sst",
313
+ 2 if self.in_sst && e.local_name().as_ref() == "si" => {
314
+ self.read_si(buf)?;
315
+ self.depth -= 1;
316
+ return Ok(true);
317
+ }
318
+ _ => {
319
+ self.skip(buf)?;
320
+ self.depth -= 1;
321
+ }
322
+ }
323
+ }
324
+ Event::Empty(e) => {
325
+ if self.depth == 1 && self.in_sst && e.local_name().as_ref() == "si" {
326
+ self.plain.push("")?;
327
+ if self.html {
328
+ self.html_strings.push("")?;
329
+ }
330
+ return Ok(true);
331
+ }
332
+ }
333
+ Event::End(_) => self.depth = self.depth.saturating_sub(1),
334
+ Event::Eof => return Ok(false),
335
+ _ => {}
336
+ }
337
+ }
338
+ }
339
+
340
+ /// `SharedStrings#extract_shared_strings` and `#extract_html` for one
341
+ /// open `si`.
342
+ fn read_si(&mut self, buf: &mut Vec<u8>) -> Result<()> {
343
+ self.item_plain.clear();
344
+ self.item_html.clear();
345
+ loop {
346
+ buf.clear();
347
+ let (child, empty) = match self.read_event(buf)? {
348
+ Event::Start(e) => (name(&e), false),
349
+ Event::Empty(e) => (name(&e), true),
350
+ Event::End(_) => break,
351
+ Event::Eof => return Err(Unsupported("unexpected end of a shared string".into())),
352
+ _ => continue,
353
+ };
354
+ match (child, empty) {
355
+ (Name::T, false) => {
356
+ self.read_t(buf)?;
357
+ self.item_plain.clear();
358
+ self.push_fixed_content();
359
+ if self.html {
360
+ self.item_html.push_str(&self.content);
361
+ }
362
+ }
363
+ (Name::T, true) => self.item_plain.clear(),
364
+ (Name::R, false) => self.read_run(buf)?,
365
+ (_, false) => self.skip(buf)?,
366
+ (_, true) => {}
367
+ }
368
+ }
369
+ self.plain.push(&self.item_plain)?;
370
+ if self.html {
371
+ self.html_strings.push(&self.item_html)?;
372
+ }
373
+ Ok(())
374
+ }
375
+
376
+ /// `SharedStrings#extract_html_r` for one open `r`, which also adds the
377
+ /// text of its `t` children to the plain string.
378
+ fn read_run(&mut self, buf: &mut Vec<u8>) -> Result<()> {
379
+ let mut format = Format::default();
380
+ loop {
381
+ buf.clear();
382
+ let (child, empty) = match self.read_event(buf)? {
383
+ Event::Start(e) => (name(&e), false),
384
+ Event::Empty(e) => (name(&e), true),
385
+ Event::End(_) => return Ok(()),
386
+ Event::Eof => return Err(Unsupported("unexpected end of a run".into())),
387
+ _ => continue,
388
+ };
389
+ match (child, empty) {
390
+ (Name::T, _) => {
391
+ if empty {
392
+ self.content.clear();
393
+ self.node_starts.clear();
394
+ } else {
395
+ self.read_t(buf)?;
396
+ }
397
+ self.push_fixed_content();
398
+ if self.html {
399
+ format.wrap(&self.content, &mut self.item_html);
400
+ }
401
+ }
402
+ (Name::RPr, false) => self.read_run_properties(buf, &mut format)?,
403
+ (_, false) => self.skip(buf)?,
404
+ (_, true) => {}
405
+ }
406
+ }
407
+ }
408
+
409
+ /// The children of an open `rPr` that turn on an HTML tag.
410
+ fn read_run_properties(&mut self, buf: &mut Vec<u8>, format: &mut Format) -> Result<()> {
411
+ loop {
412
+ buf.clear();
413
+ match self.read_event(buf)? {
414
+ Event::Start(e) => {
415
+ self.run_property(&e, format)?;
416
+ self.skip(buf)?;
417
+ }
418
+ Event::Empty(e) => self.run_property(&e, format)?,
419
+ Event::End(_) => return Ok(()),
420
+ Event::Eof => return Err(Unsupported("unexpected end of run properties".into())),
421
+ _ => {}
422
+ }
423
+ }
424
+ }
425
+
426
+ fn run_property(&self, e: &BytesStart<'_>, format: &mut Format) -> Result<()> {
427
+ match name(e) {
428
+ Name::B => format.b = true,
429
+ Name::I => format.i = true,
430
+ Name::U => format.u = true,
431
+ Name::VertAlign if self.html => match vert_align(e)?.as_ref() {
432
+ "subscript" => (format.sub, format.sup) = (true, false),
433
+ "superscript" => (format.sub, format.sup) = (false, true),
434
+ _ => {}
435
+ },
436
+ _ => {}
437
+ }
438
+ Ok(())
439
+ }
440
+
441
+ /// Nokogiri's `content` of an open `t`, into `self.content`. Any markup
442
+ /// inside it is left to roo: roo's `_x000D_` fix serializes the document
443
+ /// with indentation, which changes the content of such a `t`.
444
+ fn read_t(&mut self, buf: &mut Vec<u8>) -> Result<()> {
445
+ self.content.clear();
446
+ self.node_starts.clear();
447
+ let mut in_cdata = false;
448
+ loop {
449
+ buf.clear();
450
+ match self.read_event(buf)? {
451
+ Event::Text(t) => {
452
+ if in_cdata {
453
+ self.node_starts.push(self.content.len());
454
+ in_cdata = false;
455
+ }
456
+ self.content.push_str(&t.xml10_content());
457
+ }
458
+ Event::GeneralRef(r) => {
459
+ if in_cdata {
460
+ self.node_starts.push(self.content.len());
461
+ in_cdata = false;
462
+ }
463
+ push_reference(&mut self.content, &r)?;
464
+ }
465
+ Event::CData(t) => {
466
+ // libxml2 merges adjacent CDATA sections into one node.
467
+ if !in_cdata && !self.content.is_empty() {
468
+ self.node_starts.push(self.content.len());
469
+ }
470
+ in_cdata = true;
471
+ self.content.push_str(&t.xml10_content());
472
+ }
473
+ Event::End(_) => return Ok(()),
474
+ Event::Eof => return Err(Unsupported("unexpected end of a `t`".into())),
475
+ _ => return Err(Unsupported("markup inside a shared string's `t`".into())),
476
+ }
477
+ }
478
+ }
479
+
480
+ /// Appends `self.content` to the plain string as roo's
481
+ /// `fix_invalid_shared_strings` leaves it: `_x000D_` becomes a line feed
482
+ /// within each text or CDATA node, which the serialized document keeps
483
+ /// apart.
484
+ fn push_fixed_content(&mut self) {
485
+ if !self.content.contains(CARRIAGE_RETURN) {
486
+ self.item_plain.push_str(&self.content);
487
+ return;
488
+ }
489
+ let starts = std::iter::once(0).chain(self.node_starts.iter().copied());
490
+ let ends = self
491
+ .node_starts
492
+ .iter()
493
+ .copied()
494
+ .chain(std::iter::once(self.content.len()));
495
+ for (start, end) in starts.zip(ends) {
496
+ self.item_plain
497
+ .push_str(&self.content[start..end].replace(CARRIAGE_RETURN, "\n"));
498
+ }
499
+ }
500
+
501
+ /// Skips the rest of an open element.
502
+ fn skip(&mut self, buf: &mut Vec<u8>) -> Result<()> {
503
+ let mut depth = 0usize;
504
+ loop {
505
+ buf.clear();
506
+ match self.read_event(buf)? {
507
+ Event::Start(_) => depth += 1,
508
+ Event::End(_) if depth == 0 => return Ok(()),
509
+ Event::End(_) => depth -= 1,
510
+ Event::Eof => return Err(Unsupported("unexpected end of element".into())),
511
+ _ => {}
512
+ }
513
+ }
514
+ }
515
+
516
+ fn read_event<'b>(&mut self, buf: &'b mut Vec<u8>) -> Result<Event<'b>> {
517
+ let event = self.xml.read_event_into(buf)?;
518
+ match &event {
519
+ // roo's fix also replaces `_x000D_` in names, which breaks them.
520
+ Event::Start(e) | Event::Empty(e) if e.contains(CARRIAGE_RETURN) => {
521
+ Err(Unsupported(format!("{CARRIAGE_RETURN} in a tag")))
522
+ }
523
+ // Nokogiri names a processing instruction by its target, so roo
524
+ // takes `<?t x?>` for a `t`.
525
+ Event::PI(_) => Err(Unsupported("a processing instruction".into())),
526
+ _ => Ok(event),
527
+ }
528
+ }
529
+ }
530
+
531
+ /// `vertAlign.xpath('@val').first.value`. roo raises without a `val`; a
532
+ /// prefixed one is left to roo, which removes namespaces from attributes too.
533
+ fn vert_align(e: &BytesStart<'_>) -> Result<String> {
534
+ let mut value = None;
535
+ for attribute in e.attributes() {
536
+ let attribute = attribute?;
537
+ let key = attribute.key;
538
+ if key.local_name().as_ref() != "val" {
539
+ continue;
540
+ }
541
+ if key.prefix().is_some() {
542
+ return Err(Unsupported("a prefixed `val` on a `vertAlign`".into()));
543
+ }
544
+ value = Some(
545
+ attribute
546
+ .normalized_value(XmlVersion::Implicit1_0)?
547
+ .into_owned(),
548
+ );
549
+ }
550
+ value.ok_or_else(|| Unsupported("a `vertAlign` without a `val`".into()))
551
+ }
552
+
553
+ fn push_reference(text: &mut String, reference: &BytesRef<'_>) -> Result<()> {
554
+ if let Some(c) = reference.resolve_char_ref()? {
555
+ text.push(c);
556
+ return Ok(());
557
+ }
558
+ text.push(match reference.xml10_content().as_ref() {
559
+ "lt" => '<',
560
+ "gt" => '>',
561
+ "amp" => '&',
562
+ "apos" => '\'',
563
+ "quot" => '"',
564
+ other => return Err(Unsupported(format!("entity reference &{other};"))),
565
+ });
566
+ Ok(())
567
+ }
568
+
569
+ /// Calls `step` until it returns `false` (`Ok(true)`) or `interrupted`
570
+ /// turns true (`Ok(false)`).
571
+ fn until(interrupted: &AtomicBool, mut step: impl FnMut() -> Result<bool>) -> Result<bool> {
572
+ while !interrupted.load(Ordering::Acquire) {
573
+ if !step()? {
574
+ return Ok(true);
575
+ }
576
+ }
577
+ Ok(false)
578
+ }
579
+
580
+ /// Reads the shared strings at `path` with the GVL released, while another
581
+ /// thread checks that Nokogiri reads the file the same way.
582
+ fn read(
583
+ ruby: &Ruby,
584
+ path: &Path,
585
+ html: bool,
586
+ ) -> std::result::Result<SharedStringsReader<BufReader<File>>, Error> {
587
+ let mut check = SheetReader::open(path, Mode::Document).map_err(|e| unsupported(ruby, e))?;
588
+ let mut reader = SharedStringsReader::open(path, html).map_err(|e| unsupported(ruby, e))?;
589
+ let (mut checked, mut read) = (false, false);
590
+ while !(checked && read) {
591
+ let (checking, reading) = without_gvl(|interrupted| {
592
+ std::thread::scope(|scope| {
593
+ let checking = if checked {
594
+ None
595
+ } else {
596
+ let thread = std::thread::Builder::new()
597
+ .name("roo-oxide".into())
598
+ .spawn_scoped(scope, || {
599
+ until(interrupted, || Ok(check.next_row()?.is_some()))
600
+ })
601
+ .map_err(|e| Unsupported(e.to_string()))?;
602
+ Some(thread)
603
+ };
604
+ let reading = read || until(interrupted, || reader.next_item())?;
605
+ let checking = match checking {
606
+ Some(thread) => thread.join().unwrap_or_else(|panic| resume_unwind(panic))?,
607
+ None => true,
608
+ };
609
+ Ok((checking, reading))
610
+ })
611
+ })?
612
+ .map_err(|e| unsupported(ruby, e))?;
613
+ (checked, read) = (checking, reading);
614
+ }
615
+ Ok(reader)
616
+ }
617
+
618
+ /// `Native.shared_strings(path, html, plain)`: plain strings and deferred HTML
619
+ /// for roo's `SharedStrings`, with `nil` for an unrequested table. Raises
620
+ /// `Roo::Oxide::UnsupportedError` for a file roo reads differently.
621
+ fn shared_strings(
622
+ ruby: &Ruby,
623
+ path: String,
624
+ html: bool,
625
+ plain: bool,
626
+ ) -> std::result::Result<(Option<RArray>, Option<HtmlStrings>), Error> {
627
+ let build_plain = plain;
628
+ let SharedStringsReader {
629
+ plain,
630
+ html_strings,
631
+ ..
632
+ } = read(ruby, Path::new(&path), html)?;
633
+ let plain = if build_plain {
634
+ Some(plain.into_array(ruby)?)
635
+ } else {
636
+ drop(plain);
637
+ None
638
+ };
639
+ let html = if html {
640
+ Some(HtmlStrings {
641
+ cached: ruby.ary_new().into(),
642
+ strings: RefCell::new(Some(html_strings)),
643
+ })
644
+ } else {
645
+ None
646
+ };
647
+ Ok((plain, html))
648
+ }
649
+
650
+ pub fn define(native: magnus::RModule) -> std::result::Result<(), Error> {
651
+ let ruby = Ruby::get().expect("GVL held");
652
+ let html = native.define_class("HtmlStrings", ruby.class_object())?;
653
+ html.define_method("to_a", magnus::method!(HtmlStrings::to_a, 0))?;
654
+ native.define_singleton_method("shared_strings", magnus::function!(shared_strings, 3))?;
655
+ Ok(())
656
+ }
657
+
658
+ #[cfg(test)]
659
+ mod tests {
660
+ use super::{has_html_tag, string_end};
661
+
662
+ #[test]
663
+ fn finds_the_tags_roo_looks_for() {
664
+ for html in [
665
+ "<html><b>x</b></html>",
666
+ "a<i>",
667
+ "<u>",
668
+ "x<sup>2</sup>",
669
+ "<sub>",
670
+ ] {
671
+ assert!(has_html_tag(html.as_bytes()), "{html}");
672
+ }
673
+ for plain in ["<html>x</html>", "<br>", "<bb>", "b>", "<s>", "<su>", "<"] {
674
+ assert!(!has_html_tag(plain.as_bytes()), "{plain}");
675
+ }
676
+ }
677
+
678
+ #[test]
679
+ fn offsets_reject_overflow_instead_of_wrapping() {
680
+ assert_eq!(string_end(u32::MAX as usize - 1, 1).unwrap(), u32::MAX);
681
+ assert!(string_end(u32::MAX as usize, 1).is_err());
682
+ assert!(string_end(usize::MAX, 1).is_err());
683
+ }
684
+ }