inkmark 0.1.4 → 0.2.1

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.
data/README.md CHANGED
@@ -38,6 +38,7 @@ A very fast, feature-packed, AI-first Markdown gem for Ruby.
38
38
  - [Plain-text extraction](#plain-text-extraction)
39
39
  - [Markdown-to-Markdown pipeline](#markdown-to-markdown-pipeline)
40
40
  - [Event handlers](#event-handlers)
41
+ - [Ractors](#ractors)
41
42
  - [Benchmarks](#benchmarks)
42
43
  - [Contributing](#contributing)
43
44
  - [Acknowledgements](#acknowledgements)
@@ -177,9 +178,15 @@ g.options.math = true
177
178
  g.options.tables = false
178
179
  g.to_html
179
180
 
180
- # Process-level defaults, to set in your application initializer
181
- Inkmark.default_options.math = true
182
- Inkmark.new(md).to_html # picks up the default
181
+ # Process-wide defaults, set once in your application initializer
182
+ Inkmark.configure do |options|
183
+ options.math = true
184
+ options.links = { nofollow: true }
185
+ end
186
+ Inkmark.new(md).to_html # picks up the defaults
187
+
188
+ # Or replace them wholesale
189
+ Inkmark.default_options = { preset: :recommended, math: true }
183
190
  ```
184
191
 
185
192
  Unknown option keys raise `ArgumentError` immediately, including via the
@@ -1029,6 +1036,21 @@ Post-render filters (`syntax_highlight`, allowlists, `images: { lazy: true }`,
1029
1036
  highlighter
1030
1037
  - Handler-set `dest=` values pass through host and scheme allowlists
1031
1038
 
1039
+ ## Ractors
1040
+
1041
+ Inkmark is Ractor-safe: every public API works inside non-main Ractors,
1042
+ so documents can be rendered in parallel without leaving the native fast
1043
+ path.
1044
+
1045
+ ```ruby
1046
+ workers = sources.each_slice(250).map do |slice|
1047
+ Ractor.new(slice) do |batch|
1048
+ batch.map { |src| Inkmark.to_html(src, options: { preset: :recommended }) }
1049
+ end
1050
+ end
1051
+ html = workers.flat_map(&:value) # Ruby 4.0; use #take on 3.3 and 3.4
1052
+ ```
1053
+
1032
1054
  ## Benchmarks
1033
1055
 
1034
1056
  Inkmark ships a benchmark harness comparing it against `kramdown`,
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "inkmark"
3
- version = "0.1.4"
3
+ version = "0.2.1"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -10,12 +10,12 @@ publish = false
10
10
  crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
- magnus = { version = "0.8.2" }
14
- rb-sys = { version = "0.9.126", features = ["stable-api-compiled-fallback"] }
13
+ magnus = { version = "0.9.0", default-features = false }
14
+ rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
15
15
  pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
16
16
  pulldown-cmark-escape = "0.11"
17
17
  deunicode = "1.6"
18
- emojis = "0.8"
18
+ emojis = "0.9"
19
19
  whatlang = "0.18"
20
20
  unicode-segmentation = "1.12"
21
21
  syntect = { version = "5.2", default-features = false, features = ["parsing", "html", "default-themes", "default-syntaxes", "regex-fancy"] }
@@ -28,4 +28,4 @@ pulldown-cmark-to-cmark = "22"
28
28
  rb-sys-env = "0.2.2"
29
29
 
30
30
  [dev-dependencies]
31
- rb-sys-test-helpers = { version = "0.2.2" }
31
+ rb-sys-test-helpers = { version = "0.3.0" }
@@ -54,17 +54,15 @@ pub fn native_chunks_by_heading(
54
54
  // when there are no headings at all. Emitted as an entry with
55
55
  // `heading: nil, level: 0, id: nil`. Skipped entirely when there
56
56
  // is no non-empty content before the first heading.
57
- let with_counts = flags.statistics;
57
+ let content_opts = ContentOptions {
58
+ with_counts: flags.statistics,
59
+ truncate_params: truncate_params.as_ref(),
60
+ };
58
61
  let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len());
59
62
  if preamble_end > 0 {
60
63
  let preamble_events = &events[0..preamble_end];
61
64
  if !is_empty_content(preamble_events) {
62
- result.push(build_preamble_hash(
63
- ruby,
64
- preamble_events,
65
- with_counts,
66
- truncate_params.as_ref(),
67
- )?)?;
65
+ result.push(build_preamble_hash(ruby, preamble_events, &content_opts)?)?;
68
66
  }
69
67
  }
70
68
 
@@ -87,16 +85,17 @@ pub fn native_chunks_by_heading(
87
85
  }
88
86
  let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
89
87
  let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
88
+ // Content = events after End(Heading) up to the next section or
89
+ // end of document. Re-serialized through cmark_write.
90
+ let content_events = &events[(boundary.end + 1)..section_end];
90
91
  let hash = build_section_hash(
91
92
  ruby,
92
- &events,
93
- boundary,
94
- section_end,
93
+ boundary.level,
95
94
  &heading_text,
96
95
  &breadcrumb,
96
+ content_events,
97
97
  &mut dedup,
98
- with_counts,
99
- truncate_params.as_ref(),
98
+ &content_opts,
100
99
  )?;
101
100
  result.push(hash)?;
102
101
  ancestors.push((level, heading_text));
@@ -113,6 +112,13 @@ struct HeadingBoundary {
113
112
  level: HeadingLevel,
114
113
  }
115
114
 
115
+ /// How each entry's `content` is rendered: optionally truncated, and
116
+ /// optionally accompanied by `character_count` / `word_count`.
117
+ struct ContentOptions<'a> {
118
+ with_counts: bool,
119
+ truncate_params: Option<&'a TruncateParams>,
120
+ }
121
+
116
122
  fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
117
123
  let mut boundaries = Vec::new();
118
124
  let mut i = 0;
@@ -165,8 +171,7 @@ fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize)
165
171
  fn build_preamble_hash(
166
172
  ruby: &Ruby,
167
173
  events: &[Event<'_>],
168
- with_counts: bool,
169
- truncate_params: Option<&TruncateParams>,
174
+ content_opts: &ContentOptions<'_>,
170
175
  ) -> Result<RHash, Error> {
171
176
  let hash = ruby.hash_new();
172
177
  hash.aset(ruby.to_symbol("heading"), ())?;
@@ -176,12 +181,12 @@ fn build_preamble_hash(
176
181
  // with proper sections so callers can treat every entry alike.
177
182
  hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
178
183
 
179
- let content = match truncate_params {
184
+ let content = match content_opts.truncate_params {
180
185
  Some(params) => truncate::truncate_events(events, params),
181
186
  None => render_markdown(events),
182
187
  };
183
- if with_counts {
184
- let (chars, words) = count_post_truncate(events, truncate_params, &content);
188
+ if content_opts.with_counts {
189
+ let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content);
185
190
  hash.aset(ruby.to_symbol("character_count"), chars)?;
186
191
  hash.aset(ruby.to_symbol("word_count"), words)?;
187
192
  }
@@ -191,14 +196,12 @@ fn build_preamble_hash(
191
196
 
192
197
  fn build_section_hash(
193
198
  ruby: &Ruby,
194
- events: &[Event<'_>],
195
- boundary: &HeadingBoundary,
196
- section_end: usize,
199
+ level: HeadingLevel,
197
200
  heading_text: &str,
198
201
  breadcrumb: &[&str],
202
+ content_events: &[Event<'_>],
199
203
  dedup: &mut SlugDeduplicator,
200
- with_counts: bool,
201
- truncate_params: Option<&TruncateParams>,
204
+ content_opts: &ContentOptions<'_>,
202
205
  ) -> Result<RHash, Error> {
203
206
  // Slug is the deduplicated slugify of the (filter-applied) heading
204
207
  // text, matching the ids `heading_ids` / `toc` would emit for the
@@ -210,13 +213,9 @@ fn build_section_hash(
210
213
  dedup.deduplicate(base)
211
214
  };
212
215
 
213
- // Content = events after End(Heading) up to the next section or
214
- // end of document. Re-serialized through cmark_write.
215
- let content_events = &events[(boundary.end + 1)..section_end];
216
-
217
216
  let hash = ruby.hash_new();
218
217
  hash.aset(ruby.to_symbol("heading"), heading_text)?;
219
- hash.aset(ruby.to_symbol("level"), toc::level_to_u8(boundary.level))?;
218
+ hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?;
220
219
  if id.is_empty() {
221
220
  hash.aset(ruby.to_symbol("id"), ())?;
222
221
  } else {
@@ -228,12 +227,13 @@ fn build_section_hash(
228
227
  }
229
228
  hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
230
229
 
231
- let content = match truncate_params {
230
+ let content = match content_opts.truncate_params {
232
231
  Some(params) => truncate::truncate_events(content_events, params),
233
232
  None => render_markdown(content_events),
234
233
  };
235
- if with_counts {
236
- let (chars, words) = count_post_truncate(content_events, truncate_params, &content);
234
+ if content_opts.with_counts {
235
+ let (chars, words) =
236
+ count_post_truncate(content_events, content_opts.truncate_params, &content);
237
237
  hash.aset(ruby.to_symbol("character_count"), chars)?;
238
238
  hash.aset(ruby.to_symbol("word_count"), words)?;
239
239
  }
@@ -283,7 +283,7 @@ fn render_to_plain_text(source: &str, cm_opts: pulldown_cmark::Options, flags: F
283
283
  }
284
284
 
285
285
  let events = apply_filters(parser.collect(), &flags);
286
- plain_text::write_plain_text(events.into_iter(), &mut buf);
286
+ plain_text::write_plain_text(events, &mut buf);
287
287
  buf
288
288
  }
289
289
 
@@ -16,34 +16,23 @@ use pulldown_cmark::{CowStr, Event, Tag, TagEnd};
16
16
  /// Tracks code-block nesting depth so shortcodes inside fenced code blocks
17
17
  /// are preserved. Inline code (`Event::Code`) is passed through untouched
18
18
  /// because we only scan `Event::Text` events.
19
- pub fn replace(events: &mut Vec<Event<'_>>) {
19
+ pub fn replace(events: &mut [Event<'_>]) {
20
20
  let mut code_depth: usize = 0;
21
21
 
22
- for i in 0..events.len() {
23
- match &events[i] {
22
+ for event in events.iter_mut() {
23
+ match event {
24
24
  Event::Start(Tag::CodeBlock(_)) => {
25
25
  code_depth += 1;
26
- continue;
27
26
  }
28
27
  Event::End(TagEnd::CodeBlock) => {
29
28
  code_depth = code_depth.saturating_sub(1);
30
- continue;
31
29
  }
32
- Event::Text(_) if code_depth == 0 => {}
33
- _ => continue,
34
- }
35
-
36
- // Take ownership of the text so we can feed it to `replace_shortcodes`
37
- // and emit a new Text event with the result.
38
- if let Event::Text(text) = std::mem::replace(&mut events[i], Event::SoftBreak) {
39
- match replace_shortcodes(&text) {
40
- Some(replaced) => {
41
- events[i] = Event::Text(CowStr::Boxed(replaced.into_boxed_str()));
42
- }
43
- None => {
44
- events[i] = Event::Text(text);
30
+ Event::Text(text) if code_depth == 0 => {
31
+ if let Some(replaced) = replace_shortcodes(text) {
32
+ *text = CowStr::Boxed(replaced.into_boxed_str());
45
33
  }
46
34
  }
35
+ _ => {}
47
36
  }
48
37
  }
49
38
  }
@@ -696,11 +696,7 @@ fn apply_mutations(node: &mut Node, event_obj: Value, _ruby: &Ruby) -> Result<()
696
696
  Ok(())
697
697
  }
698
698
 
699
- pub fn dispatch_handlers(
700
- nodes: &mut Vec<Node>,
701
- handlers: &RHash,
702
- ruby: &Ruby,
703
- ) -> Result<(), Error> {
699
+ pub fn dispatch_handlers(nodes: &mut [Node], handlers: &RHash, ruby: &Ruby) -> Result<(), Error> {
704
700
  for node in nodes.iter_mut() {
705
701
  dispatch_handlers(&mut node.children, handlers, ruby)?;
706
702
 
@@ -1,4 +1,6 @@
1
- #![forbid(unsafe_code)]
1
+ // `deny` rather than `forbid`: the single `unsafe` block in `init` is
2
+ // allowed explicitly; everything else stays unsafe-free.
3
+ #![deny(unsafe_code)]
2
4
 
3
5
  use magnus::{function, prelude::*, Error, Ruby};
4
6
 
@@ -22,11 +24,34 @@ mod truncate;
22
24
  mod url_match;
23
25
 
24
26
  #[magnus::init]
27
+ #[allow(unsafe_code)]
25
28
  fn init(ruby: &Ruby) -> Result<(), Error> {
29
+ // Declare every method defined below Ractor-safe. Ruby marks the methods an
30
+ // extension defines while `Init_*` runs as unsafe unless this flag is set,
31
+ // and calling such a method from a non-main Ractor raises
32
+ // `Ractor::UnsafeError`. The extension keeps no Ruby `VALUE`s in
33
+ // process-global state: the `OnceLock` caches in `highlight` hold plain
34
+ // syntect data (`Sync`, enforced by the compiler) and the `sym_id!`
35
+ // statics in `options` hold interned symbol ids, both safe to share across
36
+ // Ractors. This must run before `define_class` so the flag covers every
37
+ // method registered below.
38
+ //
39
+ // SAFETY: `rb_ext_ractor_safe` takes no pointers and only flips a flag on
40
+ // the VM's extension-loading state. Its one precondition is being called
41
+ // while `Init_*` runs, which is exactly where `#[magnus::init]` invokes
42
+ // this function.
43
+ unsafe { rb_sys::rb_ext_ractor_safe(true) };
44
+
26
45
  let inkmark = ruby.define_class("Inkmark", ruby.class_object())?;
27
46
  inkmark.define_singleton_method("_native_to_html", function!(document::native_to_html, 2))?;
28
- inkmark.define_singleton_method("_native_to_markdown", function!(document::native_to_markdown, 2))?;
29
- inkmark.define_singleton_method("_native_to_plain_text", function!(document::native_to_plain_text, 2))?;
47
+ inkmark.define_singleton_method(
48
+ "_native_to_markdown",
49
+ function!(document::native_to_markdown, 2),
50
+ )?;
51
+ inkmark.define_singleton_method(
52
+ "_native_to_plain_text",
53
+ function!(document::native_to_plain_text, 2),
54
+ )?;
30
55
  inkmark.define_singleton_method(
31
56
  "_native_chunks_by_heading",
32
57
  function!(chunks_by_heading::native_chunks_by_heading, 2),
@@ -16,9 +16,9 @@
16
16
  //! The collector is the single source of truth for the full-render path.
17
17
  //! Two independent Ruby-side knobs consume its output:
18
18
  //! - `statistics: true` => scalar counts and language
19
- //! detection (`to_statistics_hash`)
19
+ //! detection (`to_statistics_hash`)
20
20
  //! - `extract: {...}` => filtered arrays of structured
21
- //! records (`to_extracts_hash`)
21
+ //! records (`to_extracts_hash`)
22
22
 
23
23
  use std::ops::Range;
24
24
 
@@ -293,10 +293,8 @@ pub fn collect(events: &[(Event<'_>, Range<usize>)]) -> Stats {
293
293
  }
294
294
  }
295
295
  }
296
- Event::SoftBreak | Event::HardBreak => {
297
- if !in_code_block {
298
- text_buf.push(' ');
299
- }
296
+ Event::SoftBreak | Event::HardBreak if !in_code_block => {
297
+ text_buf.push(' ');
300
298
  }
301
299
  _ => {}
302
300
  }
@@ -22,7 +22,7 @@ pub struct TocEntry {
22
22
  pub fn toc_to_markdown(entries: &[TocEntry], max_depth: Option<u8>) -> String {
23
23
  let filtered: Vec<&TocEntry> = entries
24
24
  .iter()
25
- .filter(|e| max_depth.map_or(true, |max| level_to_u8(e.level) <= max))
25
+ .filter(|e| max_depth.is_none_or(|max| level_to_u8(e.level) <= max))
26
26
  .collect();
27
27
  if filtered.is_empty() {
28
28
  return String::new();
@@ -61,7 +61,7 @@ pub fn toc_to_markdown(entries: &[TocEntry], max_depth: Option<u8>) -> String {
61
61
  pub fn toc_to_html(entries: &[TocEntry], max_depth: Option<u8>) -> String {
62
62
  let filtered: Vec<&TocEntry> = entries
63
63
  .iter()
64
- .filter(|e| max_depth.map_or(true, |max| level_to_u8(e.level) <= max))
64
+ .filter(|e| max_depth.is_none_or(|max| level_to_u8(e.level) <= max))
65
65
  .collect();
66
66
  if filtered.is_empty() {
67
67
  return String::new();
@@ -22,22 +22,28 @@ class Inkmark
22
22
  # Per-element-policy schemas. Each entry is +{ default:, types: }+; the
23
23
  # validators use +types+ for type checking and +default+ to seed fresh
24
24
  # nested hashes. Keep in sync with {NESTED_TO_FLAT}.
25
+ #
26
+ # Every constant in this class is frozen all the way down, not just
27
+ # at the top level: a non-main Ractor may only read constants whose
28
+ # whole object graph is frozen (+Ractor.shareable?+), so the inner
29
+ # Hashes and Arrays are frozen explicitly and the larger nested
30
+ # tables go through +Ractor.make_shareable+.
25
31
  HEADINGS_SCHEMA = {
26
- attributes: {default: false, types: [TrueClass, FalseClass]},
27
- ids: {default: false, types: [TrueClass, FalseClass]}
32
+ attributes: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
33
+ ids: {default: false, types: [TrueClass, FalseClass].freeze}.freeze
28
34
  }.freeze
29
35
 
30
36
  IMAGES_SCHEMA = {
31
- lazy: {default: false, types: [TrueClass, FalseClass]},
32
- allowed_hosts: {default: nil, types: [NilClass, Array]},
33
- allowed_schemes: {default: nil, types: [NilClass, Array]}
37
+ lazy: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
38
+ allowed_hosts: {default: nil, types: [NilClass, Array].freeze}.freeze,
39
+ allowed_schemes: {default: nil, types: [NilClass, Array].freeze}.freeze
34
40
  }.freeze
35
41
 
36
42
  LINKS_SCHEMA = {
37
- autolink: {default: false, types: [TrueClass, FalseClass]},
38
- nofollow: {default: false, types: [TrueClass, FalseClass]},
39
- allowed_hosts: {default: nil, types: [NilClass, Array]},
40
- allowed_schemes: {default: nil, types: [NilClass, Array]}
43
+ autolink: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
44
+ nofollow: {default: false, types: [TrueClass, FalseClass].freeze}.freeze,
45
+ allowed_hosts: {default: nil, types: [NilClass, Array].freeze}.freeze,
46
+ allowed_schemes: {default: nil, types: [NilClass, Array].freeze}.freeze
41
47
  }.freeze
42
48
 
43
49
  # Registry of nested hash options => their schemas. Iterated by the
@@ -52,7 +58,7 @@ class Inkmark
52
58
  # Map from +(parent, child)+ user-facing keys to the flat key name the
53
59
  # Rust side reads. Used by {#to_native_hash} / {#to_native_hash_frozen}
54
60
  # to serialize the user-shaped hash into the FFI wire format.
55
- NESTED_TO_FLAT = {
61
+ NESTED_TO_FLAT = Ractor.make_shareable({
56
62
  [:headings, :attributes] => :heading_attributes,
57
63
  [:headings, :ids] => :heading_ids,
58
64
  [:images, :lazy] => :lazy_images,
@@ -62,7 +68,7 @@ class Inkmark
62
68
  [:links, :nofollow] => :nofollow_external_links,
63
69
  [:links, :allowed_hosts] => :allowed_link_hosts,
64
70
  [:links, :allowed_schemes] => :allowed_link_schemes
65
- }.freeze
71
+ })
66
72
 
67
73
  # Build a frozen defaults hash for a nested schema from its +default+
68
74
  # entries.
@@ -200,11 +206,11 @@ class Inkmark
200
206
  # the accepted type set (nil-default-but-Array-when-set, polymorphic
201
207
  # +toc+, nested-hash element-policy groups).
202
208
  TYPES = {
203
- extract: [NilClass, Hash],
204
- toc: [TrueClass, FalseClass, Hash],
205
- headings: [Hash],
206
- images: [Hash],
207
- links: [Hash]
209
+ extract: [NilClass, Hash].freeze,
210
+ toc: [TrueClass, FalseClass, Hash].freeze,
211
+ headings: [Hash].freeze,
212
+ images: [Hash].freeze,
213
+ links: [Hash].freeze
208
214
  }.freeze
209
215
 
210
216
  # Element kinds accepted inside +extract: { ... }+. Mirrors the match
@@ -237,7 +243,7 @@ class Inkmark
237
243
  # The GFM tagfilter stays on. **Dangerous.** Use only for content
238
244
  # the caller fully trusts (internal team-authored docs). The
239
245
  # caller is fully responsible for sanitizing output.
240
- PRESETS = {
246
+ PRESETS = Ractor.make_shareable({
241
247
  commonmark: {
242
248
  gfm: false,
243
249
  gfm_tag_filter: false,
@@ -245,7 +251,7 @@ class Inkmark
245
251
  strikethrough: false,
246
252
  tasklists: false,
247
253
  footnotes: false
248
- }.freeze,
254
+ },
249
255
 
250
256
  gfm: {
251
257
  gfm: true,
@@ -254,7 +260,7 @@ class Inkmark
254
260
  strikethrough: true,
255
261
  tasklists: true,
256
262
  footnotes: true
257
- }.freeze,
263
+ },
258
264
 
259
265
  recommended: {
260
266
  gfm: true,
@@ -272,7 +278,7 @@ class Inkmark
272
278
  syntax_highlight: true,
273
279
  hard_wrap: true,
274
280
  frontmatter: true
275
- }.freeze,
281
+ },
276
282
 
277
283
  trusted: {
278
284
  gfm: true,
@@ -290,8 +296,8 @@ class Inkmark
290
296
  syntax_highlight: true,
291
297
  hard_wrap: true,
292
298
  frontmatter: true
293
- }.freeze
294
- }.freeze
299
+ }
300
+ })
295
301
 
296
302
  # Preset applied by {#initialize} when the caller doesn't pass
297
303
  # +preset:+. +:gfm+ matches {DEFAULTS}, so the default constructor
@@ -387,7 +393,16 @@ class Inkmark
387
393
  # hashes; the input value as-is otherwise)
388
394
  # @raise [ArgumentError] if +key+ is unknown, or the value (or any
389
395
  # nested sub-value) has the wrong type
396
+ # @raise [FrozenError] if this instance is frozen (as
397
+ # {Inkmark.default_options} always is)
390
398
  def []=(key, value)
399
+ if frozen?
400
+ raise FrozenError.new(
401
+ "can't modify frozen #{self.class}: use Inkmark.configure to change " \
402
+ "the process-wide defaults, or dup for a mutable copy",
403
+ receiver: self
404
+ )
405
+ end
391
406
  validate_key!(key)
392
407
  # Deep-merge partial nested-hash overrides (+:headings+,
393
408
  # +:images+, +:links+) so callers pass only the sub-keys they
@@ -429,11 +444,17 @@ class Inkmark
429
444
  # FFI calls that don't need to add per-call params. The cache is
430
445
  # invalidated in {#[]=} and {#initialize_copy}.
431
446
  #
432
- # @return [Hash{Symbol => Object}] frozen, shared across calls until
433
- # a mutation invalidates it
447
+ # The hash is frozen all the way down, as a copy: nested Arrays and
448
+ # Hashes are duplicated before freezing so caller-supplied values
449
+ # (an +allowed_hosts+ Array, say) stay mutable in the caller's hands.
450
+ # A deeply frozen memo is what lets a frozen +Options+ be shared
451
+ # across Ractors.
452
+ #
453
+ # @return [Hash{Symbol => Object}] deeply frozen, shared across calls
454
+ # until a mutation invalidates it
434
455
  # @api private
435
456
  def to_native_hash_frozen
436
- @frozen_native_hash ||= build_native_hash.freeze
457
+ @frozen_native_hash ||= deep_frozen_copy(build_native_hash)
437
458
  end
438
459
 
439
460
  # Return a new Options instance with +other+'s values applied on top.
@@ -457,6 +478,18 @@ class Inkmark
457
478
  end
458
479
  alias_method :eql?, :==
459
480
 
481
+ # Freeze this instance. The memoized FFI hash is computed first, while
482
+ # the instance is still mutable, so {#to_native_hash_frozen} never has
483
+ # to write to a frozen object. +Ractor.make_shareable+ calls +freeze+
484
+ # on every object it visits, which is what makes a shared
485
+ # {Inkmark.default_options} renderable from any Ractor.
486
+ #
487
+ # @return [self]
488
+ def freeze
489
+ to_native_hash_frozen
490
+ super
491
+ end
492
+
460
493
  # Duplicate this instance, deep-copying the internal values hash so the
461
494
  # clone is fully independent from the original.
462
495
  def initialize_copy(orig)
@@ -466,14 +499,26 @@ class Inkmark
466
499
  @frozen_native_hash = nil
467
500
  end
468
501
 
469
- # @!macro [attach] inkmark_options_accessor
470
- # @!attribute [rw] $1
471
- # Reader and writer for the +$1+ option. The writer routes through
472
- # {#[]=} so key validation and (for nested groups) deep-merge apply
473
- # uniformly.
502
+ # Reader and writer for every option key. The writer routes through
503
+ # {#[]=} so key validation and (for nested groups) deep-merge apply
504
+ # uniformly.
505
+ #
506
+ # Generated from source strings rather than +define_method+ blocks:
507
+ # a block-defined method carries its Proc, and Ruby refuses to call
508
+ # such a method from a non-main Ractor ("defined with an un-shareable
509
+ # Proc in a different Ractor"). Plain +def+ bodies have no such
510
+ # baggage. +key+ is always a Symbol from {DEFAULTS}, so interpolating
511
+ # it is safe.
474
512
  DEFAULTS.each_key do |key|
475
- define_method(key) { @values[key] }
476
- define_method("#{key}=") { |value| self[key] = value }
513
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
514
+ def #{key} # def tables
515
+ @values[:#{key}] # @values[:tables]
516
+ end # end
517
+
518
+ def #{key}=(value) # def tables=(value)
519
+ self[:#{key}] = value # self[:tables] = value
520
+ end # end
521
+ RUBY
477
522
  end
478
523
 
479
524
  private
@@ -517,6 +562,19 @@ class Inkmark
517
562
  def validate_key!(key) = self.class.send(:validate_key!, key)
518
563
  def validate_value!(key, value) = self.class.send(:validate_value!, key, value)
519
564
 
565
+ # Return a frozen copy of +value+ with every nested Hash, Array, and
566
+ # String frozen too. Scalars (booleans, nil, Integers, Symbols) are
567
+ # returned as is. Only the container shapes {#build_native_hash} can
568
+ # produce are handled.
569
+ def deep_frozen_copy(value)
570
+ case value
571
+ when Hash then value.transform_values { |v| deep_frozen_copy(v) }.freeze
572
+ when Array then value.map { |v| deep_frozen_copy(v) }.freeze
573
+ when String then -value
574
+ else value
575
+ end
576
+ end
577
+
520
578
  # Build the Rust-facing flat hash: nested element-policy hashes expand
521
579
  # into their flat keys via {NESTED_TO_FLAT}; the internal +@toc_depth+
522
580
  # is injected when set.
@@ -624,10 +682,13 @@ class Inkmark
624
682
  # +options: { preset: :name }+ call pattern, which would otherwise
625
683
  # build a fresh +Options+ instance (seed defaults, 6–14 +[]=+
626
684
  # with validation, +build_native_hash+) on every call. The cached
627
- # hashes are frozen and safe to share across threads.
628
- PRESETS_NATIVE_HASH = PRESETS.keys.each_with_object({}) do |name, h|
629
- h[name] = new(preset: name).to_native_hash_frozen
630
- end.freeze
685
+ # hashes are deeply frozen and safe to share across threads and
686
+ # Ractors.
687
+ PRESETS_NATIVE_HASH = Ractor.make_shareable(
688
+ PRESETS.keys.each_with_object({}) do |name, h|
689
+ h[name] = new(preset: name).to_native_hash_frozen
690
+ end
691
+ )
631
692
 
632
693
  # Build a Rust-facing flat hash from +overrides+ without allocating
633
694
  # an +Options+ instance or walking +build_native_hash+. Starts from
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Inkmark
4
4
  # Current gem version.
5
- VERSION = "0.1.4"
5
+ VERSION = "0.2.1"
6
6
  end