Version 1.19.0 - #348
Merged
Merged
Conversation
Values like "12E5" and "0047583311587E590003" are identifiers far more often than scientific notation; 1.18.0's exponent conversion corrupted them irreversibly (Infinity). Exponent-shaped values now always stay Strings on both the C and Ruby paths, as in every version before 1.18.0. Plain integers and decimals are unaffected, as is decimal_precision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFif67ZYKunZgdhKXJGrcK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed ") Output written with a custom quote_char (e.g. "'") could not be read back: escaping doubled the custom quote_char correctly, but the field was then wrapped in a literal double quote. Round-trip through Writer and Reader with the same custom quote_char is now tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ensure clause also ran on the early enum_for return, where original_chunk_size was never captured, restoring nil — so a later each_chunk on the same Reader ignored the configured chunk size. Scoped with an explicit begin/ensure, matching each_chunk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With strip_whitespace: true the C path only stripped space and tab, so a stray trailing \r survived on CRLF lines in mixed LF/CRLF files, and in CRLF files read with an explicit row_sep of LF. trim_field now strips exactly what the Ruby path's String#strip! does: space, \t, \n, \v, \f, \r, and \0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pattern is written against what's in the file, but the C path converted values to numbers at parse time, so /\A007\z/ never matched (the matcher only saw 7). With nil_values_matching set, the C parser now defers numeric conversion and zero-removal to the Ruby hash transformations, which apply the pattern to the raw string first — the same order as the pure-Ruby path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
option_valid? accepted :auto for quote_char although only row_sep and col_sep have auto-detection; the Reader then crashed with NoMethodError from '@quote_char * 2'. quote_char must be a non-empty String. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With headers name,name,name2 the second 'name' was renamed to 'name2' (default suffix + counter), colliding with the real third column, and check_duplicate_headers then raised DuplicateHeaders — defeating the disambiguation feature. The duplicate branch now bumps its counter past taken names, like the blank-header branch always did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When rows contained more columns than headers, the reader appended column_N entries directly into the caller's array (and into options[:user_provided_headers], so a reused options hash silently changed behavior on the next file). process_headers now adopts a dup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
headers: { only: } / { except: } values were always normalized to
Symbols, but with strings_as_keys / keep_original_headers the row keys
are Strings — nothing matched, and with only: every row came back empty
(then was dropped by remove_empty_hashes): silent total data loss.
Selectors are now normalized to the row-key type on both paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
process_headers ignored the parser's unclosed-quote signal (size -1): the first header fragment was silently lost and the second fragment was parsed as a data row. Headers now stitch across physical lines exactly like the data-row loop; an unclosed quote at EOF raises MalformedCSV. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With strings_as_keys: true and duplicate_header_suffix: nil, an empty header produces a '' String key. The Ruby path drops it; the C-path cleanup only deleted the :"" Symbol form. Both cleanup sites now delete both forms, and @delete_empty_keys also detects the String form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A one-character multi-byte col_sep (e.g. 'é') passed the size == 1 gate but was then scanned by its first byte only — which also occurs as the lead byte of other characters — producing mid-character slices and an ArgumentError on quoted lines. Gating on bytesize sends multi-byte separators down the character-level path, matching the C parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Latin-1 data mislabeled as UTF-8 made the Ruby fallback raise ArgumentError from encoding-aware operations (split, strip!, blank?'s regex) — an error on_bad_row: :skip couldn't even quarantine. Invalid lines are now processed as BINARY bytes and the fields re-tagged with the original encoding — bytes preserved exactly, never transcoded, matching the C path. blank? treats invalid-byte strings as non-blank. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the coverage move out of the corner-cases scratch file: the out-of-range parity block there tested 1e400, -1e400, and 1e-400; the committed contract block only had 1e400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unquoted field starting with a multi-byte character followed by a literal quote (é"x) advanced the byte loop onto a UTF-8 continuation byte; String#byteindex then raised IndexError (offset not on character boundary) at the col_sep skip-ahead in both parse_csv_line_ruby and detect_multiline_strict. The skip-ahead now uses the byte loop when the scan position is mid-character. Found by differential fuzzing; the C path parsed these inputs fine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror Ruby's String#chomp("\n") semantics (removes \r\n, \r, or \n)
in chomp_row_sep. The literal chomp left the \r in the line, so a CRLF
line whose last field is quoted raised MalformedCSV even with default
options, and strip_whitespace: false yielded "x\r" / String "1\r"
where Ruby yields "x" / Integer 1. Found by differential fuzzing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes a regression from the raw-string-matching fix: the C parser defers numeric conversion and zero-removal when nil_values_matching is set, but the accelerated post-processing only applied the matcher — so numeric conversion was silently off for the whole file. The acceleration branch now runs hash_transformations (nil-match on raw strings first, then zero-removal, numeric conversion, value_converters — pure-Ruby order). Also guard the transformation regexes (nil_values_matching, ZERO_REGEX, NUMERIC_REGEX) against invalid-encoding values, which raised ArgumentError on the Ruby path for values like "1\xFF". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
detect_multiline_strict disagreed with the parser three ways, making the Ruby path fabricate 'Unclosed quoted field' (MalformedCSV) on rows the parser closes: no doubled-quote precedence (issue #334 rule), no backslash-escape awareness (:backslash, and the primary interpretation of :auto), and it walked the unchomped line so end-of-line close decisions flipped. The gate now models the parser's rules exactly and, under :auto, reports still-open only when both the backslash and RFC interpretations are open (mirroring the dual quote counting). Adds spec/smarter_csv/parity_fuzz_spec.rb: a seeded, deterministic differential fuzz asserting C and Ruby paths produce identical results (same rows, same value classes, or the same error class) across two alphabets and eleven option sets. 30k randomized inputs run clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The separator comparison loops exited at end-of-buffer with the match flag still true, so with col_sep '||' a value or header ending in a lone '|' silently lost that character. A separator now only matches when it fits completely before endP; the same bound fixes an out-of-bounds read in is_valid_close for multi-char separators near end-of-line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Empty line yields zero fields on the C path (like Ruby's "".split),
so remove_empty_values: false pads ALL columns with nil — no more
{a: "", b: nil, ...} divergence.
- A nil entry in user_provided_headers drops that column on the C path
too (@delete_nil_keys now detects nil in the headers).
- Extra-column keys are interned as UTF-8 symbols (rb_enc_sprintf +
rb_str_intern), fixing EncodingError for non-ASCII
missing_header_prefix.
- col_sep > 7 bytes, row_sep > 15, missing_header_prefix > 63 bytes
fall back to the pure-Ruby parser instead of being silently truncated
by the C parse context's fixed-size buffers.
- Fix stale comment: quoting does not suppress numeric conversion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared empty-string object (one object for all empty field values, by design — no allocation per empty field) was mutable: appending to one empty value silently changed every other empty value in the result. Mutation now raises FrozenError. Also tagged UTF-8 like Ruby's empty strings (was ASCII-8BIT). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the shared-empty-string design on the Ruby path: empty fields now become the frozen EMPTY_STRING constant at hash building, so kept results retain one object instead of one fresh string per empty field (the fresh strings from split die in the next minor GC), and mutating an empty value raises FrozenError on both paths. Relevant with remove_empty_values: false; the default true removes empties anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m 4096 The C path converted a huge digit-only field to a number before the Ruby-side size check (which only measures Strings), so the limit never fired — and Bignum conversion of a huge digit string is exactly the expensive overrun the option exists to prevent (cost grows with the square of the digit count). insert_field_into_hash now checks the raw field size before any conversion and raises FieldSizeLimitExceeded with the same message as the Ruby path; on_bad_row quarantines it as usual. field_size_limit is overrun protection, not per-field validation: values below 4096 now raise a ValidationError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The point of only: is to stop parsing each row after the last wanted column. The C path did that (early exit); the Ruby path parsed every column, built the full hash, deleted unwanted keys afterwards — and so discovered extra columns the C path never saw (reader.headers grew with column_N) and raised MalformedCSV for an unclosed quote in an unwanted trailing column that C ignores. parse_line_to_hash_ruby now honors _early_exit_after: limited split on the fast path, max-fields cap in parse_csv_line_ruby on the quoted path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Ruby path's blank-row test is value.strip.empty?, and String#strip also removes NUL bytes — so a row of only NULs is dropped as blank. The C path kept it with strip_whitespace: false. insert_field_into_hash now returns row-blank for fields consisting only of the String#strip byte set (space, \t, \n, \v, \f, \r, \0); the inserted VALUE itself is unchanged, so with remove_empty_hashes: false the NUL byte stays data on both paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Hash form requires exactly one of only:/except: with field name(s)
(String/Symbol or an Array of them). An empty hash, unknown keys, both
keys together, or nil/boolean values raise a ValidationError — the C and
Ruby paths silently disagreed on all of these shapes ({} meant 'convert
nothing' on C and 'convert everything' on Ruby; with both keys C obeyed
only: and Ruby obeyed except:). The listed names are normalized to the
row-key type (like headers: {only:}), fixing the silent no-op under
strings_as_keys / keep_original_headers on both paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The table is a quick reference; the validation details are in the CHANGELOG. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An empty Array makes no sense as a field-name list — ValidationError, like the other degenerate Hash shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Multi-char col_sep sub-path of the stitch gate: doubled-quote and backslash-escape stitching tests (both paths). - Close-quote before an embedded row separator: parser (via the parse API) and detector (private-method unit test) row_sep arms. - Invalid decimal_precision ValidationError message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some corners are only reachable when options interact (e.g. strip_whitespace: false + remove_empty_values: false). 12k randomized combo inputs verified clean offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wraps every feature spec that parses — general, quotes, chunked, formatting, auto-detection, BOM/unicode/binary separators, headers, file_encoding end-to-end, skip_lines, and the peekable-IO integration suite — in [true, false] acceleration loops, so both parser implementations run the same assertions (~420 additional examples). Wrapping and option-merging only: no assertion was changed or deleted (verified: zero expect lines in the whitespace-ignoring diff). No parity failures surfaced. Suite: 3164 examples, 100% line coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grouped into Reverted Behavior Changes / Behavior Changes / Bug Fixes / C-Ruby parity / Tests, one line per fix — every item kept, detail lives in the commits and PR. Test counts measured by rspec --dry-run: 1.18.1 (main) 2,595 examples -> 1.19.0 3,164 examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wizard gets the four actionable 1.19.0 migration items (exponent forms stay Strings, field_size_limit minimum 4096, convert_values_to_numeric Hash validation, frozen shared empty string) and latest is now 1.19. The reader quote_char row in options.md states the single-byte requirement, matching the writer row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- convert_values_to_numeric Hash-form validation (data_transformations) - shared frozen empty string with remove_empty_values: false - nil entry in user_provided_headers drops the column (both pages that say the array is used as-is) - quoted multiline headers are stitched like data rows - invalid bytes / mislabeled encoding row in the real-world table Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reverted Behavior Changes
Version 1.18.0 started converting scientific notation (
"1e3","12E5","1.5e3") to Floats. In real-world CSV data, digits-E-digits values are far more often identifiers (short codes, hex IDs) than scientific notation, and the auto-conversion corrupted them irreversibly — an ID like"0047583311587E590003"came back asInfinity.As of 1.19.0, exponent forms always stay Strings — as they did in every version before 1.18.0. If a column really does contain scientific notation, convert it per-column with
value_converters:Thanks to Denis Sadomowski for the report.
Bug Fixes
A partial multi-char separator at end-of-line is no longer consumed as a separator on the C path (C/Ruby parity — silent data loss). With
col_sep: '||', a value or header ending in a lone|lost that character ("y|"came back as"y"). The separator comparison (and the close-quote lookahead) is now bounded by the end of the line, which also removes an out-of-bounds read for multi-char separators near end-of-line.An empty line now yields
nilfor ALL columns on the C path too (C/Ruby parity,remove_empty_values: false). The C path gave the first column an empty string ({a: "", b: nil, ...}) where the Ruby path — matching"".split— yields no fields, so every column is padded withnil.A
nilentry inuser_provided_headersnow drops that column on the C path too (C/Ruby parity). Thenilkey survived into the row hashes on the accelerated path.Non-ASCII
missing_header_prefix(e.g."spalte_ä_") no longer raisesEncodingErroron the C path. Generated extra-column keys are now interned as UTF-8 symbols.The Hash form of
convert_values_to_numericis now validated and normalized. It requires exactly one ofonly:/except:with field name(s) (String/Symbol or an Array of them); an empty hash, unknown keys, both keys together, ornil/boolean values raise aValidationError— previously the C and Ruby paths silently disagreed on these shapes (e.g.{}meant "convert nothing" on the C path and "convert everything" on the Ruby path). The listed names are normalized to the row-key type, soonly:/except:now also works together withstrings_as_keys/keep_original_headers(Symbol selectors silently matched nothing there before, on both paths).A row consisting only of NUL bytes now counts as blank on the C path too (C/Ruby parity). The blank-row test follows Ruby's
value.strip.empty?, andString#stripalso removes NUL bytes (\0) — the C path kept such rows withstrip_whitespace: false. The per-field value is unchanged: withremove_empty_hashes: falsea NUL byte is still kept as data on both paths.headers: { only: }now short-cuts on the pure-Ruby path too (C/Ruby parity + speed). The point ofonly:is to stop parsing each row right after the last wanted column — the C path did that; the Ruby path parsed every column, built the full row hash, and then deleted the unwanted keys, so it also discovered extra columns behind the last wanted one (reader.headersgrew with:column_Nentries the C path never saw) and raisedMalformedCSVfor an unclosed quote in an unwanted trailing column that the C path ignores. Both paths now stop identically after the last wanted column.field_size_limitnow also catches oversized digit-only fields on the C path (C/Ruby parity). The C path converted a huge digit field to a number before the size check (which only measured Strings), so the limit never fired — and converting e.g. a 200KB digit string to an Integer is exactly the expensive overrun the option exists to prevent. The C parser now checks the raw field size before any conversion. Additionally,field_size_limitvalues below4096now raise aValidationError— the option is overrun protection, not per-field validation.All empty field values are now ONE shared, frozen, UTF-8 empty-string object — on both paths. This was the C path's design (no per-empty-field object retained in the results), but the shared object was mutable — appending to one empty value silently changed every other empty value in the result — and the Ruby path allocated a fresh string per empty field. Mutating an empty value now raises
FrozenErroron both paths. Relevant withremove_empty_values: false; with the defaulttrue, empty values are removed anyway.Exotic option sizes fall back to the pure-Ruby parser instead of silently truncating. The C parse context stores
col_sep(7 bytes),row_sep(15), andmissing_header_prefix(63) in fixed-size buffers; longer values produced wrong results on the accelerated path. The reader now automatically uses the pure-Ruby parser for these, which handles any length.Writer: fields are now wrapped in the configured
quote_char, not a hard-coded double quote. Output written with a customquote_char(e.g."'") could not be read back: the custom quote_char was doubled correctly inside the field, but the field itself was wrapped in".Reader#eachwithout a block no longer clears the configuredchunk_size. Callingeachin its Enumerator form (no block) overwroteoptions[:chunk_size]withnil, so a latereach_chunkon the same Reader ignored the configured chunk size.C path now strips a stray trailing
\rfrom values (C/Ruby parity). Withstrip_whitespace: true(the default), the C-accelerated path only stripped spaces and tabs, so a\rsurvived at the end of the last field on CRLF lines in mixed LF/CRLF files (and in CRLF files read with an explicitrow_sep: "\n"). The C path now strips exactly Ruby'sString#stripcharacter set (space,\t,\n,\v,\f,\r,\0), matching the pure-Ruby path.nil_values_matchingnow matches the raw string value on the C path too (C/Ruby parity). The pattern is written against what's in the file, but the C-accelerated path converted values to numbers first — so a pattern like/\A007\z/never matched (the matcher only ever saw7). Whennil_values_matchingis set, the C parser now defers numeric conversion and zero-removal to the Ruby hash transformations, which apply the pattern to the raw string first — the same order as the pure-Ruby path.quote_char: :autonow raises aValidationError. There is no auto-detection forquote_char(only forrow_sepandcol_sep), but validation accepted:autoand the Reader then crashed with aNoMethodError.Duplicate-header disambiguation no longer collides with a real column name. With headers
name,name,name2, the secondnamewas renamed toname2(default suffix + counter), colliding with the real third column — and the reader then raisedDuplicateHeaders, defeating the disambiguation feature. The counter is now bumped past taken names (the secondnamebecomesname3).The
user_provided_headersarray is no longer mutated. When rows contained more columns than headers, the reader appendedcolumn_Nentries directly into the caller's array (and intooptions[:user_provided_headers], so a reused options hash silently changed behavior on the next file). The reader now works on its own copy.headers: { only: }/{ except: }now works together withstrings_as_keys/keep_original_headers. The selector values were always normalized to Symbols, but in those modes the row keys are Strings — so nothing matched, and withheaders: { only: }every row came back empty (then was dropped byremove_empty_hashes): silent total data loss. The selectors are now normalized to the row-key type.A quoted header containing an embedded newline is now stitched across physical lines, like data rows. Previously the first header fragment was silently lost and the second fragment was parsed as a data row — silent corruption. The embedded newline becomes
_via the standard header transformations ("first\nname"→:first_name); an unclosed quote that reaches end-of-file raisesMalformedCSV.An empty-string header key is now dropped on the C path too (C/Ruby parity). With
strings_as_keys: trueandduplicate_header_suffix: nil(which disables thecolumn_Nauto-naming), an empty header produced a''String key that the Ruby path dropped but the C path kept (its cleanup only deleted the:""Symbol form).A one-character, multi-byte
col_sep(e.g.'é') no longer crashes the pure-Ruby parser (C/Ruby parity). The Ruby parser's byte-level fast path was gated on the separator's character count, then scanned for its first byte only — which also occurs as the lead byte of other characters — and raisedArgumentErroron quoted lines. The fast path is now gated onbytesize; multi-byte separators take the character-level path, matching the C parser's results.The multiline stitch gate no longer fabricates
MalformedCSVon rows the parser can close (pure-Ruby path). The gate (detect_multiline_strict) disagreed with the parser in three ways: it lacked the doubled-quote precedence rule (""inside a quoted field, issue Escaped double quote followed by comma issue #334), it had no backslash-escape awareness (quote_escaping: :backslash, and the primary interpretation of:auto), and it walked the unchomped line, flipping end-of-line close decisions. The gate now models the parser's rules exactly, and under:autoreports "still open" only when both the backslash and RFC interpretations are open. A seeded differential fuzz spec (parity_fuzz_spec.rb) now guards C/Ruby parity permanently; 30,000 randomized inputs run clean on both paths.A trailing
\rbefore an LF row separator is now treated as part of the line terminator on the C path too (C/Ruby parity). Ruby'sString#chomp("\n")removes\r\n,\r, or\n; the C path chomped the row separator literally, so the surviving\rmade a CRLF line whose last field is quoted raiseMalformedCSV(even with default options), and withstrip_whitespace: falsevalues came back as"x\r"/"1\r"(String) instead of"x"/1. Found by differential fuzzing.Multi-byte characters directly before a literal quote no longer crash the pure-Ruby parser (C/Ruby parity). An unquoted field like
é"xmade the Ruby parser's byte-level skip-ahead handString#byteindexa mid-character byte offset —IndexError: offset does not land on character boundary. The skip-ahead now falls back to the byte loop when the scan position is mid-character. Found by differential fuzzing against the C path, which parsed these fine.Invalid bytes in the input no longer crash the pure-Ruby parser (C/Ruby parity). Typical case: Latin-1 data mislabeled as UTF-8. The C path parses leniently and preserves the field's raw bytes; the Ruby fallback raised
ArgumentError("invalid byte sequence in UTF-8") — which is not aSmarterCSV::Error, so evenon_bad_row: :skipcouldn't quarantine it. The Ruby parser now processes such lines at the byte level and re-tags the fields with the original encoding — bytes preserved exactly, never transcoded, so the data stays recoverable (e.g. viaforce_encoding('ISO-8859-1')). The value-transformation regexes (nil_values_matching, zero-removal, numeric conversion) skip invalid-encoding values instead of raising. Cleanup remains opt-in viaforce_utf8/invalid_byte_sequence.nil_values_matchingno longer switches off numeric conversion and zero-removal on the C path. (Fixes a regression introduced while making the option match raw strings, above: the C parser correctly deferred those transformations to Ruby, but the accelerated post-processing never ran them.) Non-matching values now get numeric conversion, zero-removal, andvalue_convertersin the same order as the pure-Ruby path.Tests
[true, false]acceleration loops (~420 additional examples), a seeded differential parity-fuzz spec (2,000 randomized inputs per run, including combined option sets) guards C/Ruby parity permanently, and line coverage is at 100%.Test results
RSpec tests: 2,595 → 3,164 (+569 tests) — every parsing spec runs on BOTH the C-accelerated and the pure-Ruby path via
[true, false]acceleration loops; a seeded differential parity-fuzz spec (2,000 randomized inputs per run, including combined option sets) guards C/Ruby parity permanently; line coverage is at 100% (1546/1546 lines). Verified clean with 30,000+ randomized fuzz inputs offline. Rubocop: no offenses in changed files.