nosj 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +105 -0
- data/Cargo.lock +17 -17
- data/README.md +32 -15
- data/ext/nosj/Cargo.toml +6 -6
- data/ext/nosj/fuzz/Cargo.toml +1 -1
- data/ext/nosj/fuzz/src/prelude.rb +20 -44
- data/ext/nosj/src/files.rs +21 -20
- data/ext/nosj/src/gen/errors.rs +12 -10
- data/ext/nosj/src/gen/mod.rs +8 -10
- data/ext/nosj/src/gen/opts.rs +76 -141
- data/ext/nosj/src/gen/ruby.rs +50 -22
- data/ext/nosj/src/gen/walker.rs +219 -102
- data/ext/nosj/src/lazy.rs +60 -50
- data/ext/nosj/src/lib.rs +4 -0
- data/ext/nosj/src/lines.rs +27 -17
- data/ext/nosj/src/locate.rs +162 -0
- data/ext/nosj/src/opt_reader.rs +170 -0
- data/ext/nosj/src/parse.rs +170 -99
- data/ext/nosj/src/patch.rs +25 -15
- data/ext/nosj/src/pointer.rs +9 -11
- data/ext/nosj/src/reformat.rs +83 -140
- data/ext/nosj/src/sink.rs +193 -60
- data/ext/nosj/src/state.rs +65 -14
- data/ext/nosj/src/stats.rs +35 -33
- data/lib/nosj/json.rb +105 -39
- data/lib/nosj/version.rb +1 -1
- data/lib/nosj.rb +36 -22
- data/sig/nosj.rbs +4 -3
- metadata +4 -2
data/ext/nosj/src/lib.rs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
//! - `parse.rs`: whole-document entry points (parse, valid?, the
|
|
5
5
|
//! GVL-releasing indexed parse) plus shared option decoding and
|
|
6
6
|
//! input gating.
|
|
7
|
+
//! - `opt_reader.rs`: options-hash reading with json 3's unknown-key
|
|
8
|
+
//! rule, shared by the parse and generate decoders.
|
|
7
9
|
//! - `pointer.rs`: partial parsing (dig, at_pointer, batch forms).
|
|
8
10
|
//! - `lazy.rs`: lazy documents (NOSJ.lazy nodes resolving access on
|
|
9
11
|
//! demand over shared document bytes).
|
|
@@ -23,6 +25,8 @@ pub mod files;
|
|
|
23
25
|
pub mod gen;
|
|
24
26
|
pub mod lazy;
|
|
25
27
|
pub mod lines;
|
|
28
|
+
pub mod locate;
|
|
29
|
+
pub mod opt_reader;
|
|
26
30
|
pub mod parse;
|
|
27
31
|
pub mod patch;
|
|
28
32
|
pub mod pointer;
|
data/ext/nosj/src/lines.rs
CHANGED
|
@@ -24,22 +24,31 @@ fn blank(line: &[u8]) -> bool {
|
|
|
24
24
|
/// Yield one parsed value per non-blank line. Each line parses through
|
|
25
25
|
/// the shared sink machinery against the FULL source, so a malformed
|
|
26
26
|
/// line raises the rich ParserError whose `#line` is the physical
|
|
27
|
-
/// NDJSON line number.
|
|
28
|
-
///
|
|
29
|
-
|
|
27
|
+
/// NDJSON line number. No slice is held across a yield: the block runs
|
|
28
|
+
/// arbitrary Ruby (even deduplicating a frozen source swaps its buffer,
|
|
29
|
+
/// see `lazy::DocBytes`), so `source` hands out the bytes afresh for
|
|
30
|
+
/// every line, with identical content and so identical offsets.
|
|
31
|
+
fn walk_lines<'s>(
|
|
32
|
+
ruby: &Ruby,
|
|
33
|
+
source: impl Fn() -> &'s [u8],
|
|
34
|
+
o: &ParseNativeOpts,
|
|
35
|
+
) -> Result<(), Error> {
|
|
30
36
|
let mut pos = 0;
|
|
31
|
-
|
|
32
|
-
let
|
|
37
|
+
loop {
|
|
38
|
+
let bytes = source();
|
|
39
|
+
if pos >= bytes.len() {
|
|
40
|
+
return Ok(());
|
|
41
|
+
}
|
|
42
|
+
let line_end = bytes[pos..]
|
|
33
43
|
.iter()
|
|
34
44
|
.position(|&b| b == b'\n')
|
|
35
|
-
.map_or(
|
|
36
|
-
if !blank(&
|
|
37
|
-
let value = materialize_at(ruby,
|
|
45
|
+
.map_or(bytes.len(), |p| pos + p);
|
|
46
|
+
if !blank(&bytes[pos..line_end]) {
|
|
47
|
+
let value = materialize_at(ruby, bytes, pos, line_end, o)?;
|
|
38
48
|
let _: Value = ruby.yield_value(value)?;
|
|
39
49
|
}
|
|
40
50
|
pos = line_end + 1;
|
|
41
51
|
}
|
|
42
|
-
Ok(())
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/// `NOSJ.each_line(source, opts) { |value| }`: the Ruby wrapper
|
|
@@ -53,15 +62,16 @@ pub fn each_line_native(
|
|
|
53
62
|
let o = parse_native_opts(ruby, opts)?;
|
|
54
63
|
let input = utf8_input(ruby, &data)?;
|
|
55
64
|
if data.as_value().is_frozen() {
|
|
56
|
-
// A frozen source
|
|
57
|
-
//
|
|
58
|
-
|
|
65
|
+
// A frozen source keeps its content (never its buffer, see
|
|
66
|
+
// walk_lines), so it is re-read per line, zero-copy.
|
|
67
|
+
// SAFETY: validated UTF-8 above; `data` lives on this frame.
|
|
68
|
+
walk_lines(ruby, || unsafe { data.as_slice() }, &o)?;
|
|
59
69
|
} else {
|
|
60
|
-
// The block could
|
|
61
|
-
//
|
|
62
|
-
//
|
|
70
|
+
// The block could rewrite an unfrozen source mid-iteration;
|
|
71
|
+
// walk a private copy. Same policy as NOSJ.lazy: pass a frozen
|
|
72
|
+
// string for zero-copy.
|
|
63
73
|
let owned = input.to_vec();
|
|
64
|
-
walk_lines(ruby, &owned, &o)?;
|
|
74
|
+
walk_lines(ruby, || &owned, &o)?;
|
|
65
75
|
}
|
|
66
76
|
Ok(ruby.qnil().as_value())
|
|
67
77
|
}
|
|
@@ -84,7 +94,7 @@ pub fn each_line_file_native(
|
|
|
84
94
|
return Ok(ruby.qnil().as_value());
|
|
85
95
|
}
|
|
86
96
|
with_mapped_file(ruby, &p, |map| {
|
|
87
|
-
walk_lines(ruby, &map, &o)?;
|
|
97
|
+
walk_lines(ruby, || &map, &o)?;
|
|
88
98
|
Ok(ruby.qnil().as_value())
|
|
89
99
|
})
|
|
90
100
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
//! Cold-path positions for the refusals a sink detects without offsets.
|
|
2
|
+
//!
|
|
3
|
+
//! Sinks see events, not byte positions, so a duplicate key (found by
|
|
4
|
+
//! hash size or key fingerprints) or a lone surrogate (delivered as
|
|
5
|
+
//! WTF-8) aborts the drive with no location. These walks re-read the
|
|
6
|
+
//! same bytes with the crate's pull `Reader`, under the same grammar
|
|
7
|
+
//! options, to find where. Each is linear and iterative: one `Reader`
|
|
8
|
+
//! over the whole document, an explicit stack instead of recursion, so
|
|
9
|
+
//! no nesting depth is out of reach.
|
|
10
|
+
|
|
11
|
+
use std::collections::HashSet;
|
|
12
|
+
|
|
13
|
+
use nosj::{Buffers, Node, ParseError, ParseOptions, Reader};
|
|
14
|
+
|
|
15
|
+
use crate::parse::span_of;
|
|
16
|
+
|
|
17
|
+
/// What the exact check found behind a sink's duplicate-key refusal.
|
|
18
|
+
pub(crate) enum Repeat {
|
|
19
|
+
/// The first object, in the order objects close (as the drive meets
|
|
20
|
+
/// them), that repeats a key: the offset of its `{` and the key.
|
|
21
|
+
Found { at: usize, key: String },
|
|
22
|
+
/// No object repeats a key: the refusal was a fingerprint collision.
|
|
23
|
+
Absent,
|
|
24
|
+
/// The walk stopped on a Reader error before deciding. Callers
|
|
25
|
+
/// treat it as a refusal.
|
|
26
|
+
Undecided,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub(crate) fn duplicate_key(doc: &[u8], popts: ParseOptions) -> Repeat {
|
|
30
|
+
match scan(doc, popts, true) {
|
|
31
|
+
Ok(None) => Repeat::Absent,
|
|
32
|
+
Ok(Some((path, key))) => match container_offset(doc, &path, popts) {
|
|
33
|
+
Ok(at) => Repeat::Found { at, key },
|
|
34
|
+
Err(_) => Repeat::Undecided,
|
|
35
|
+
},
|
|
36
|
+
Err(_) => Repeat::Undecided,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// The first error a full walk of `doc` meets, decoding every string
|
|
41
|
+
/// and key: where a lone surrogate the sink refused sits (the Reader
|
|
42
|
+
/// rejects them with a position).
|
|
43
|
+
pub(crate) fn first_walk_error(doc: &[u8], popts: ParseOptions) -> Option<ParseError> {
|
|
44
|
+
scan(doc, popts, false).err()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// One open container of [`scan`]'s walk.
|
|
48
|
+
struct Frame {
|
|
49
|
+
object: bool,
|
|
50
|
+
/// The member or element being walked.
|
|
51
|
+
index: usize,
|
|
52
|
+
seen: HashSet<String>,
|
|
53
|
+
repeated: Option<String>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl Frame {
|
|
57
|
+
fn open(object: bool) -> Self {
|
|
58
|
+
Frame {
|
|
59
|
+
object,
|
|
60
|
+
index: 0,
|
|
61
|
+
seen: HashSet::new(),
|
|
62
|
+
repeated: None,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn note_key(&mut self, key: &str) {
|
|
67
|
+
if self.repeated.is_none() && !self.seen.insert(key.to_owned()) {
|
|
68
|
+
self.repeated = Some(key.to_owned());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Walk all of `doc`, depth first. With `find_repeats`, stop at the
|
|
74
|
+
/// first object to close with a repeated key: the member/element
|
|
75
|
+
/// indices from the root down to it, and the key.
|
|
76
|
+
fn scan(
|
|
77
|
+
doc: &[u8],
|
|
78
|
+
popts: ParseOptions,
|
|
79
|
+
find_repeats: bool,
|
|
80
|
+
) -> Result<Option<(Vec<usize>, String)>, ParseError> {
|
|
81
|
+
let mut bufs = Buffers::new();
|
|
82
|
+
// SAFETY: `doc` is validated UTF-8.
|
|
83
|
+
let mut r = unsafe { Reader::from_utf8_unchecked_with(doc, &mut bufs, popts) };
|
|
84
|
+
let mut stack: Vec<Frame> = Vec::new();
|
|
85
|
+
loop {
|
|
86
|
+
// The cursor is at a value: open it, or step past it.
|
|
87
|
+
let opened = match r.next_node()? {
|
|
88
|
+
Node::ObjectStart => match r.object_first_key()? {
|
|
89
|
+
Some(key) => {
|
|
90
|
+
let mut frame = Frame::open(true);
|
|
91
|
+
if find_repeats {
|
|
92
|
+
frame.note_key(key);
|
|
93
|
+
}
|
|
94
|
+
Some(frame)
|
|
95
|
+
}
|
|
96
|
+
None => None,
|
|
97
|
+
},
|
|
98
|
+
Node::ArrayStart => r.array_first()?.then(|| Frame::open(false)),
|
|
99
|
+
_ => None,
|
|
100
|
+
};
|
|
101
|
+
if let Some(frame) = opened {
|
|
102
|
+
stack.push(frame);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// The value is complete: advance its container, closing every
|
|
106
|
+
// container it completes on the way up.
|
|
107
|
+
loop {
|
|
108
|
+
let Some(top) = stack.last_mut() else {
|
|
109
|
+
return Ok(None);
|
|
110
|
+
};
|
|
111
|
+
let more = if top.object {
|
|
112
|
+
match r.object_next_key()? {
|
|
113
|
+
Some(key) => {
|
|
114
|
+
if find_repeats {
|
|
115
|
+
top.note_key(key);
|
|
116
|
+
}
|
|
117
|
+
true
|
|
118
|
+
}
|
|
119
|
+
None => false,
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
r.array_next()?
|
|
123
|
+
};
|
|
124
|
+
if more {
|
|
125
|
+
top.index += 1;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if let Some(key) = stack.pop().and_then(|closed| closed.repeated) {
|
|
129
|
+
return Ok(Some((stack.iter().map(|f| f.index).collect(), key)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// The offset of the container at `path`, member/element indices from
|
|
136
|
+
/// the root: walk down skipping earlier siblings, then skip the
|
|
137
|
+
/// container itself, whose slice starts at its bracket.
|
|
138
|
+
fn container_offset(doc: &[u8], path: &[usize], popts: ParseOptions) -> Result<usize, ParseError> {
|
|
139
|
+
let mut bufs = Buffers::new();
|
|
140
|
+
// SAFETY: `doc` is validated UTF-8.
|
|
141
|
+
let mut r = unsafe { Reader::from_utf8_unchecked_with(doc, &mut bufs, popts) };
|
|
142
|
+
// Every index on the path was walked by `scan`, so each step lands
|
|
143
|
+
// on an existing member.
|
|
144
|
+
for &index in path {
|
|
145
|
+
let object = matches!(r.next_node()?, Node::ObjectStart);
|
|
146
|
+
if object {
|
|
147
|
+
r.object_first_key()?;
|
|
148
|
+
} else {
|
|
149
|
+
r.array_first()?;
|
|
150
|
+
}
|
|
151
|
+
for _ in 0..index {
|
|
152
|
+
r.skip_value()?;
|
|
153
|
+
if object {
|
|
154
|
+
r.object_next_key()?;
|
|
155
|
+
} else {
|
|
156
|
+
r.array_next()?;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let container = r.skip_value()?;
|
|
161
|
+
Ok(span_of(doc, container.as_bytes()).0)
|
|
162
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
//! Options-hash reading under json 3's unknown-key rule: every key of an
|
|
2
|
+
//! options hash must be one the entry point reads, else ArgumentError
|
|
3
|
+
//! in json 3's wording ("unknown keyword: foo"). A reader counts the
|
|
4
|
+
//! keys it finds, so a clean hash costs one length compare; only a
|
|
5
|
+
//! shortfall walks the hash to name the keys nobody read.
|
|
6
|
+
|
|
7
|
+
use magnus::r_hash::ForEach;
|
|
8
|
+
use magnus::value::ReprValue;
|
|
9
|
+
use magnus::{Error, RHash, Ruby, Symbol, Value};
|
|
10
|
+
|
|
11
|
+
macro_rules! options {
|
|
12
|
+
($($variant:ident => $name:literal,)*) => {
|
|
13
|
+
/// Every option any entry point reads. The discriminant is the
|
|
14
|
+
/// option's bit in [`OptReader`]'s masks, so a key two decoders
|
|
15
|
+
/// read from one hash (reformat takes parse and generate
|
|
16
|
+
/// options together) counts once.
|
|
17
|
+
#[derive(Clone, Copy)]
|
|
18
|
+
pub(crate) enum Opt {
|
|
19
|
+
$($variant,)*
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Ruby option names, indexed by [`Opt`] discriminant.
|
|
23
|
+
const NAMES: &[&str] = &[$($name,)*];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
options! {
|
|
28
|
+
SymbolizeNames => "symbolize_names",
|
|
29
|
+
Freeze => "freeze",
|
|
30
|
+
MaxNesting => "max_nesting",
|
|
31
|
+
AllowNan => "allow_nan",
|
|
32
|
+
AllowTrailingComma => "allow_trailing_comma",
|
|
33
|
+
AllowDuplicateKey => "allow_duplicate_key",
|
|
34
|
+
ObjectClass => "object_class",
|
|
35
|
+
ArrayClass => "array_class",
|
|
36
|
+
DecimalClass => "decimal_class",
|
|
37
|
+
OnLoad => "on_load",
|
|
38
|
+
CreateAdditions => "create_additions",
|
|
39
|
+
AllowComments => "allow_comments",
|
|
40
|
+
AllowControlCharacters => "allow_control_characters",
|
|
41
|
+
AllowInvalidEscape => "allow_invalid_escape",
|
|
42
|
+
Indent => "indent",
|
|
43
|
+
Space => "space",
|
|
44
|
+
SpaceBefore => "space_before",
|
|
45
|
+
ObjectNl => "object_nl",
|
|
46
|
+
ArrayNl => "array_nl",
|
|
47
|
+
AsciiOnly => "ascii_only",
|
|
48
|
+
ScriptSafe => "script_safe",
|
|
49
|
+
Strict => "strict",
|
|
50
|
+
Depth => "depth",
|
|
51
|
+
BufferInitialLength => "buffer_initial_length",
|
|
52
|
+
SortKeys => "sort_keys",
|
|
53
|
+
AsJson => "as_json",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const _: () = assert!(NAMES.len() <= u64::BITS as usize);
|
|
57
|
+
|
|
58
|
+
impl Opt {
|
|
59
|
+
fn bit(self) -> u64 {
|
|
60
|
+
1 << self as u32
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pub(crate) fn name(self) -> &'static str {
|
|
64
|
+
NAMES[self as usize]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub(crate) struct OptReader<'a> {
|
|
69
|
+
ruby: &'a Ruby,
|
|
70
|
+
hash: RHash,
|
|
71
|
+
/// The hash's key count, read once.
|
|
72
|
+
len: usize,
|
|
73
|
+
/// Options read and present in the hash.
|
|
74
|
+
found: u64,
|
|
75
|
+
/// Options accepted only while falsy (see [`OptReader::tolerate`]).
|
|
76
|
+
tolerated: u64,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
impl<'a> OptReader<'a> {
|
|
80
|
+
pub(crate) fn new(ruby: &'a Ruby, hash: RHash) -> Self {
|
|
81
|
+
Self {
|
|
82
|
+
ruby,
|
|
83
|
+
hash,
|
|
84
|
+
len: hash.len(),
|
|
85
|
+
found: 0,
|
|
86
|
+
tolerated: 0,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub(crate) fn ruby(&self) -> &'a Ruby {
|
|
91
|
+
self.ruby
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn all_found(&self) -> bool {
|
|
95
|
+
self.found.count_ones() as usize == self.len
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// The value under `opt`'s Symbol key; an explicit nil is present.
|
|
99
|
+
/// Once every key is found, the rest are absent without a lookup
|
|
100
|
+
/// (`{symbolize_names: true}` costs one lookup, not six). An option
|
|
101
|
+
/// read twice (reformat reads a few for both parsing and
|
|
102
|
+
/// generating) is looked up again.
|
|
103
|
+
pub(crate) fn get(&mut self, opt: Opt) -> Option<Value> {
|
|
104
|
+
if self.all_found() && self.found & opt.bit() == 0 {
|
|
105
|
+
return None;
|
|
106
|
+
}
|
|
107
|
+
// An interned StaticSymbol: no String allocation per lookup.
|
|
108
|
+
let value = self.hash.get(self.ruby.sym_new(opt.name()));
|
|
109
|
+
if value.is_some() {
|
|
110
|
+
self.found |= opt.bit();
|
|
111
|
+
}
|
|
112
|
+
value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pub(crate) fn truthy(&mut self, opt: Opt) -> bool {
|
|
116
|
+
self.get(opt).is_some_and(|v| v.to_bool())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Accept these json options, which NOSJ does not implement, only
|
|
120
|
+
/// while falsy: their default behavior is NOSJ's, anything else
|
|
121
|
+
/// would be silently ignored. No lookup happens here: such a key can
|
|
122
|
+
/// only be present when the reads leave keys unfound, so
|
|
123
|
+
/// [`OptReader::finish`] checks them on its cold path.
|
|
124
|
+
pub(crate) fn tolerate(&mut self, opts: &[Opt]) {
|
|
125
|
+
for opt in opts {
|
|
126
|
+
self.tolerated |= opt.bit();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Raise for keys no read asked for, and for tolerated options set
|
|
131
|
+
/// to something truthy.
|
|
132
|
+
pub(crate) fn finish(self) -> Result<(), Error> {
|
|
133
|
+
if self.all_found() {
|
|
134
|
+
return Ok(());
|
|
135
|
+
}
|
|
136
|
+
self.leftover_keys()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[cold]
|
|
140
|
+
#[inline(never)]
|
|
141
|
+
fn leftover_keys(self) -> Result<(), Error> {
|
|
142
|
+
let (found, tolerated) = (self.found, self.tolerated);
|
|
143
|
+
let option_of = |key: Value| {
|
|
144
|
+
let name = Symbol::from_value(key)?.name().ok()?;
|
|
145
|
+
NAMES.iter().position(|known| *known == name)
|
|
146
|
+
};
|
|
147
|
+
let mut unknown = Vec::new();
|
|
148
|
+
let mut unsupported = None;
|
|
149
|
+
self.hash.foreach(|key: Value, value: Value| {
|
|
150
|
+
match option_of(key).map(|index| 1u64 << index) {
|
|
151
|
+
Some(bit) if found & bit != 0 => {}
|
|
152
|
+
Some(bit) if tolerated & bit != 0 => {
|
|
153
|
+
if value.to_bool() {
|
|
154
|
+
unsupported = Some(key.to_string());
|
|
155
|
+
return Ok(ForEach::Stop);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
_ => unknown.push(key.to_string()),
|
|
159
|
+
}
|
|
160
|
+
Ok(ForEach::Continue)
|
|
161
|
+
})?;
|
|
162
|
+
let message = match (unsupported, unknown.as_slice()) {
|
|
163
|
+
(Some(name), _) => format!("NOSJ does not support the {name} option"),
|
|
164
|
+
(None, []) => return Ok(()),
|
|
165
|
+
(None, [key]) => format!("unknown keyword: {key}"),
|
|
166
|
+
(None, keys) => format!("unknown keywords: {}", keys.join(", ")),
|
|
167
|
+
};
|
|
168
|
+
Err(Error::new(self.ruby.exception_arg_error(), message))
|
|
169
|
+
}
|
|
170
|
+
}
|