menkar 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 19b9fc9d24c830c4eec2a522a1bec3b0b41b5a6756a52d6e2dbd945456e929f1
4
+ data.tar.gz: 285609052ef5eb84c5dee828fa4d21097279f433417bc0ca47ba2bc4e2368762
5
+ SHA512:
6
+ metadata.gz: 7502f91d1b5f17218797d38514a4e49f2615b7d74baad30823e75fb41d60d3cd8ffe790435c9d474fd2f789d71e0ad5952853b264a5da42846ff042c7af80e95
7
+ data.tar.gz: 913bd5a9b617d91c712a71f714041e3dca5330e07372ca6199b7acca6ada96973e3514ea33ef4cfe35a1997c1e7c3fe27fc4f2f42ef02bad530bf7af2c967ec6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Menkar
2
+
3
+ Menkar is a pure Ruby library for identifying text bytes before they enter a
4
+ UTF-8 editor core. It detects BOMs, binary data, encodings, newline forms, and
5
+ indentation, then performs strict and reversible transcoding.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "menkar"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "menkar"
17
+
18
+ bytes = File.binread("legacy.txt")
19
+ detection = Menkar.detect(bytes, hint: "Windows-31J")
20
+ raise "binary file" if detection.binary
21
+
22
+ text = Menkar.decode(bytes, detection) # valid UTF-8; original newlines remain
23
+ updated = text.sub("旧", "新")
24
+
25
+ unless Menkar.roundtrip?(updated, detection)
26
+ warn "The edited text cannot be saved in #{detection.encoding.name}"
27
+ end
28
+ File.binwrite("legacy.txt", Menkar.encode(updated, detection))
29
+ ```
30
+
31
+ `encode` restores the detected BOM and otherwise preserves the string's
32
+ newlines byte for byte. Newline conversion is explicit:
33
+
34
+ ```ruby
35
+ unix_text, original = Menkar.normalize_newlines(text, to: :lf)
36
+ # original is :lf, :crlf, :cr, :mixed, or :none
37
+ ```
38
+
39
+ `detect_file` reads at most `sample + 1` bytes; `detect` examines only `sample`
40
+ bytes. The default is 64 KiB and the accepted maximum is 16 MiB. Supported
41
+ legacy encodings are Windows-31J (Shift_JIS), EUC-JP, ISO-2022-JP,
42
+ Windows-1252, GBK, Big5, and EUC-KR. A hint is an encoding name, `Encoding`, or
43
+ `{encoding: ...}` and adjusts a statistical score; a BOM always wins.
44
+
45
+ Detection is evidence, not a save policy. Callers decide whether to reload or
46
+ convert, warn for mixed newlines, and require confirmation when `roundtrip?`
47
+ is false. Decoding and encoding never replace invalid or undefined characters.
48
+
49
+ ## Corpus and accuracy
50
+
51
+ The test corpus contains short excerpts from public-domain works: the Iroha,
52
+ Ogura Hyakunin Isshu, *I Am a Cat*, *The Pillow Book*, *The Tale of the Heike*,
53
+ *The Narrow Road to the Deep North*, and Basho's haiku (Japanese); the
54
+ *Analects* and *Tao Te Ching* (Chinese); *Hunminjeongeum* and traditional Korean
55
+ proverbs; and Shakespeare's *Hamlet*. `script/build_corpus` reproducibly
56
+ transcodes those excerpts into the fixture encodings. The source works are in
57
+ the public domain; the generated fixtures are distributed under the
58
+ repository's MIT license.
59
+
60
+ The suite requires at least 98% correct classification for Japanese fixtures
61
+ and 90% for the remaining legacy encodings.
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ bundle install
67
+ bundle exec rake test
68
+ bundle exec rbs -I sig validate
69
+ BUDGET=1 bundle exec rake bench
70
+ gem build --strict menkar.gemspec
71
+ ```
72
+
73
+ ## License
74
+
75
+ Menkar is available under the MIT License.
@@ -0,0 +1,17 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, data,
9
+ runtime, or component constraints.
10
+
11
+ ## Decision
12
+
13
+ Describe the durable boundary or architecture choice.
14
+
15
+ ## Consequences
16
+
17
+ Describe the important positive and negative trade-offs and when to revisit it.
@@ -0,0 +1,25 @@
1
+ # ADR 001: Keep detection bounded and transcoding strict
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ Editors need a useful encoding guess for large files, but a guess must not
9
+ silently corrupt source bytes. File reading, overwrite confirmation, and save
10
+ policy belong to the editor rather than this encoding library.
11
+
12
+ ## Decision
13
+
14
+ Detection examines a bounded prefix and reports confidence plus structural
15
+ facts. BOM evidence is decisive; otherwise standard Ruby encoders validate
16
+ candidates before small language-specific scores rank them. Decode and encode
17
+ use strict conversion, preserve detected BOMs and newlines, and expose an exact
18
+ round-trip check. The caller owns every overwrite or conversion decision.
19
+
20
+ ## Consequences
21
+
22
+ Very short or statistically unusual input can remain ambiguous, while hints
23
+ can improve its ranking. Unsupported or damaged text fails explicitly instead
24
+ of inserting replacement characters. Adding an encoding requires corpus
25
+ coverage rather than changing a caller's save path.
@@ -0,0 +1,3 @@
1
+ # Architecture decision records
2
+
3
+ These records document Menkar's durable boundaries.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menkar
4
+ DEFAULT_SAMPLE = 64 * 1024
5
+ MAX_SAMPLE = 16 * 1024 * 1024
6
+
7
+ BOMS = [
8
+ ["\xFF\xFE\x00\x00".b.freeze, Encoding::UTF_32LE],
9
+ ["\x00\x00\xFE\xFF".b.freeze, Encoding::UTF_32BE],
10
+ ["\xEF\xBB\xBF".b.freeze, Encoding::UTF_8],
11
+ ["\xFF\xFE".b.freeze, Encoding::UTF_16LE],
12
+ ["\xFE\xFF".b.freeze, Encoding::UTF_16BE]
13
+ ].freeze
14
+
15
+ SUPPORTED_ENCODINGS = ([Encoding::UTF_8, Encoding::UTF_16LE, Encoding::UTF_16BE,
16
+ Encoding::UTF_32LE, Encoding::UTF_32BE] + EncodingScorer.encodings).freeze
17
+
18
+ module_function
19
+
20
+ def detect(bytes, hint: nil, sample: DEFAULT_SAMPLE)
21
+ validate_bytes!(bytes)
22
+ limit = validate_sample!(sample)
23
+ hint_encoding = normalize_hint(hint)
24
+ chunk = (bytes.byteslice(0, limit) || "").b
25
+ truncated = bytes.bytesize > chunk.bytesize
26
+ bom, bom_encoding = BOMS.find { |mark, _encoding| chunk.start_with?(mark) }
27
+
28
+ if bom_encoding
29
+ text = EncodingScorer.decode(chunk.byteslice(bom.bytesize..) || "".b, bom_encoding, truncated: truncated)
30
+ return detection(bom_encoding, 1.0, bom, text, binary: false)
31
+ end
32
+
33
+ if chunk.include?("\0")
34
+ encoding, text = nul_encoding(chunk, truncated: truncated)
35
+ return detection(encoding, 0.95, "".b.freeze, text, binary: false) if encoding
36
+
37
+ return detection(nil, 1.0, "".b.freeze, nil, binary: true)
38
+ end
39
+
40
+ return detection(nil, 1.0, "".b.freeze, nil, binary: true) if binary_sample?(chunk)
41
+
42
+ if chunk.match?(/\e\$(?:@|B)|\e\([BJ]/n)
43
+ text = EncodingScorer.decode(chunk, Encoding::ISO_2022_JP, truncated: truncated)
44
+ return detection(Encoding::ISO_2022_JP, 0.99, "".b.freeze, text, binary: false) if text
45
+ end
46
+
47
+ utf8 = EncodingScorer.decode(chunk, Encoding::UTF_8, truncated: truncated)
48
+ if utf8
49
+ if utf8.ascii_only? && hint_encoding && hint_encoding != Encoding::UTF_8
50
+ return detection(hint_encoding, 0.6, "".b.freeze, utf8, binary: false)
51
+ end
52
+ return detection(Encoding::UTF_8, utf8.ascii_only? ? 0.5 : 0.9, "".b.freeze, utf8, binary: false)
53
+ end
54
+
55
+ encoding, confidence, text = EncodingScorer.legacy(chunk, hint_encoding, truncated: truncated)
56
+ return detection(nil, 1.0, "".b.freeze, nil, binary: true) unless encoding
57
+
58
+ detection(encoding, confidence, "".b.freeze, text, binary: false)
59
+ end
60
+
61
+ def detect_file(path, sample: DEFAULT_SAMPLE)
62
+ limit = validate_sample!(sample)
63
+ unless path.is_a?(String) && path.valid_encoding? && !path.include?("\0")
64
+ raise Error, "path must be a valid String without NUL bytes"
65
+ end
66
+
67
+ bytes = File.open(path, "rb") { |file| file.read(limit + 1) || "".b }
68
+ detect(bytes, sample: limit)
69
+ rescue SystemCallError => error
70
+ raise Error, "cannot read #{path}: #{error.message}"
71
+ end
72
+
73
+ def binary?(bytes)
74
+ detect(bytes).binary
75
+ end
76
+
77
+ def detection(encoding, confidence, bom, text, binary:)
78
+ Detection.new(encoding, confidence, bom, text && newline_kind(text), text && indent(text), binary)
79
+ end
80
+ private_class_method :detection
81
+
82
+ def indent(text)
83
+ prefixes = text.each_line.filter_map { |line| line[/\A[ \t]+(?=\S)/] }
84
+ return nil if prefixes.empty?
85
+
86
+ tabs = prefixes.count { |prefix| prefix.start_with?("\t") }
87
+ spaces = prefixes.filter_map { |prefix| prefix[/\A +/]&.length }
88
+ return Indent.new(:tab, nil) if tabs > spaces.length
89
+ return nil if spaces.empty?
90
+
91
+ widths = [2, 3, 4, 8]
92
+ width = widths.max_by { |candidate| [spaces.count { |value| (value % candidate).zero? }.fdiv(spaces.length), candidate] }
93
+ width = 1 if spaces.count { |value| (value % width).zero? }.fdiv(spaces.length) < 0.6
94
+ Indent.new(:space, width)
95
+ end
96
+ private_class_method :indent
97
+
98
+ def nul_encoding(bytes, truncated:)
99
+ options = []
100
+ if bytes.bytesize >= 4
101
+ columns = 4.times.map { |offset| bytes.bytes.each_with_index.count { |byte, index| (index % 4) == offset && byte.zero? } }
102
+ groups = bytes.bytesize / 4
103
+ options << Encoding::UTF_32LE if columns[1..].all? { |count| count.fdiv(groups) > 0.6 }
104
+ options << Encoding::UTF_32BE if columns[0, 3].all? { |count| count.fdiv(groups) > 0.6 }
105
+ end
106
+ if bytes.bytesize >= 2
107
+ pairs = bytes.bytesize / 2
108
+ even = bytes.bytes.each_with_index.count { |byte, index| index.even? && byte.zero? }.fdiv(pairs)
109
+ odd = bytes.bytes.each_with_index.count { |byte, index| index.odd? && byte.zero? }.fdiv(pairs)
110
+ options << Encoding::UTF_16LE if odd > 0.3 && even < 0.2
111
+ options << Encoding::UTF_16BE if even > 0.3 && odd < 0.2
112
+ end
113
+
114
+ options.uniq.each do |encoding|
115
+ text = EncodingScorer.decode(bytes, encoding, truncated: truncated)
116
+ return [encoding, text] if text && EncodingScorer.textual?(text)
117
+ end
118
+ [nil, nil]
119
+ end
120
+ private_class_method :nul_encoding
121
+
122
+ def binary_sample?(bytes)
123
+ return false if bytes.empty?
124
+
125
+ controls = bytes.each_byte.count { |byte| byte < 32 && ![9, 10, 12, 13, 27].include?(byte) }
126
+ controls.fdiv(bytes.bytesize) > 0.3
127
+ end
128
+ private_class_method :binary_sample?
129
+
130
+ def normalize_hint(hint)
131
+ return nil if hint.nil?
132
+ hint = hint[:encoding] || hint["encoding"] if hint.is_a?(Hash)
133
+ encoding = hint.is_a?(Encoding) ? hint : Encoding.find(hint.to_s)
134
+ encoding = Encoding::Windows_31J if encoding == Encoding::Shift_JIS
135
+ raise Error, "unsupported encoding hint: #{hint}" unless SUPPORTED_ENCODINGS.include?(encoding)
136
+
137
+ encoding
138
+ rescue ArgumentError
139
+ raise Error, "unknown encoding hint: #{hint}"
140
+ end
141
+ private_class_method :normalize_hint
142
+
143
+ def validate_bytes!(bytes)
144
+ raise Error, "bytes must be a String" unless bytes.is_a?(String)
145
+ end
146
+ private_class_method :validate_bytes!
147
+
148
+ def validate_sample!(sample)
149
+ raise Error, "sample must be an Integer between 4 and #{MAX_SAMPLE}" unless sample.is_a?(Integer) && sample.between?(4, MAX_SAMPLE)
150
+
151
+ sample
152
+ end
153
+ private_class_method :validate_sample!
154
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menkar
4
+ module EncodingScorer
5
+ CANDIDATES = [
6
+ [Encoding::Windows_31J, :japanese],
7
+ [Encoding::EUC_JP, :japanese],
8
+ [Encoding::ISO_2022_JP, :japanese],
9
+ [Encoding::GBK, :simplified_chinese],
10
+ [Encoding::Big5, :traditional_chinese],
11
+ [Encoding::EUC_KR, :korean],
12
+ [Encoding::Windows_1252, :western]
13
+ ].freeze
14
+
15
+ COMMON = {
16
+ japanese: "のにをはがとでてし日本人一日年大中本時行見言生子上来国私",
17
+ simplified_chinese: "的一是不了在人有我他这中大来上国个们为时会后发里",
18
+ traditional_chinese: "的一是不了在人有我他這中大來上國個們為時會後發裡",
19
+ korean: "이다는을를에의가이은한하로있것수나그되사",
20
+ western: "etaoinshrdlucmETAOINSHRDLUCM"
21
+ }.transform_values(&:freeze).freeze
22
+
23
+ module_function
24
+
25
+ def encodings = CANDIDATES.map(&:first)
26
+
27
+ def legacy(bytes, hint, truncated:)
28
+ scored = []
29
+ CANDIDATES.each do |encoding, language|
30
+ text = decode(bytes, encoding, truncated: truncated)
31
+ next unless text && textual?(text)
32
+
33
+ score = language_score(text, language)
34
+ score += 0.35 if encoding == hint
35
+ return [encoding, confidence(score), text] if language == :japanese && score >= 1.8
36
+
37
+ scored << [score, encoding, text]
38
+ end
39
+ return [nil, nil, nil] if scored.empty?
40
+
41
+ score, encoding, text = scored.max_by(&:first)
42
+ [encoding, confidence(score), text]
43
+ end
44
+
45
+ def decode(bytes, encoding, truncated:)
46
+ cuts = truncated ? 0..[4, bytes.bytesize].min : 0..0
47
+ cuts.each do |cut|
48
+ source = bytes.byteslice(0, bytes.bytesize - cut).dup.force_encoding(encoding)
49
+ next unless source.valid_encoding?
50
+
51
+ return source.encode(Encoding::UTF_8)
52
+ rescue EncodingError
53
+ next
54
+ end
55
+ nil
56
+ end
57
+
58
+ def textual?(text)
59
+ return true if text.empty?
60
+ return false if text.include?("\0")
61
+
62
+ text.scan(/[\x00-\x08\x0B\x0E-\x1F]/).length.fdiv(text.length) < 0.05
63
+ end
64
+
65
+ def confidence(score)
66
+ [[0.5 + (score * 0.18), 0.99].min, 0.5].max
67
+ end
68
+ private_class_method :confidence
69
+
70
+ def language_score(text, language)
71
+ total = text.count(language == :western ? "^ \t\r\n\f\v" : "^\x00-\x7F")
72
+ return 0.0 if total.zero?
73
+
74
+ ratio = ->(characters) { text.count(characters).fdiv(total) }
75
+ common = text.count(COMMON.fetch(language)).fdiv(total)
76
+
77
+ case language
78
+ when :japanese
79
+ 0.2 + ratio.call("\u3040-\u30FF") * 3.1 + ratio.call("\uFF61-\uFF9F") * 0.6 +
80
+ ratio.call("\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2FA1F}") * 0.35 + common * 0.8
81
+ when :simplified_chinese, :traditional_chinese
82
+ 0.2 + ratio.call("\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u{20000}-\u{2FA1F}") * 1.2 + common * 1.1
83
+ when :korean
84
+ 0.2 + ratio.call("\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF") * 1.5 + common
85
+ else
86
+ 0.2 + ratio.call("A-Za-z\u00C0-\u024F\u1E00-\u1EFF") * 1.2 + common * 0.6
87
+ end
88
+ end
89
+ private_class_method :language_score
90
+ end
91
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menkar
4
+ NEWLINES = {lf: "\n", crlf: "\r\n", cr: "\r"}.freeze
5
+
6
+ module_function
7
+
8
+ def decode(bytes, detection)
9
+ validate_detection!(detection)
10
+ raise Error, "cannot decode binary input" if detection.binary
11
+ raise Error, "bytes must be a String" unless bytes.is_a?(String)
12
+
13
+ source = bytes.b
14
+ unless detection.bom.empty?
15
+ raise Error, "input does not start with the detected BOM" unless source.start_with?(detection.bom)
16
+
17
+ source = source.byteslice(detection.bom.bytesize..) || "".b
18
+ end
19
+ source.force_encoding(detection.encoding)
20
+ raise Error, "invalid #{detection.encoding.name} input" unless source.valid_encoding?
21
+
22
+ source.encode(Encoding::UTF_8)
23
+ rescue EncodingError => error
24
+ raise Error, "cannot decode #{detection.encoding.name}: #{error.message}"
25
+ end
26
+
27
+ def encode(string, detection)
28
+ validate_detection!(detection)
29
+ raise Error, "cannot encode binary input" if detection.binary
30
+ raise Error, "string must be valid text" unless string.is_a?(String) && string.valid_encoding?
31
+
32
+ utf8 = string.encode(Encoding::UTF_8)
33
+ detection.bom + utf8.encode(detection.encoding).b
34
+ rescue EncodingError => error
35
+ raise Error, "cannot encode #{detection.encoding.name}: #{error.message}"
36
+ end
37
+
38
+ def roundtrip?(value, detection)
39
+ return false unless value.is_a?(String)
40
+
41
+ if value.encoding == Encoding::UTF_8 && value.valid_encoding?
42
+ decode(encode(value, detection), detection) == value
43
+ else
44
+ encode(decode(value, detection), detection) == value.b
45
+ end
46
+ rescue Error
47
+ false
48
+ end
49
+
50
+ def normalize_newlines(string, to: :lf)
51
+ raise Error, "string must be valid text" unless string.is_a?(String) && string.valid_encoding?
52
+ replacement = NEWLINES.fetch(to) { raise Error, "newline must be :lf, :crlf, or :cr" }
53
+ utf8 = string.encode(Encoding::UTF_8)
54
+ original = newline_kind(utf8)
55
+ [utf8.gsub(/\r\n|\r|\n/, replacement), original]
56
+ end
57
+
58
+ def validate_detection!(detection)
59
+ unless detection.is_a?(Detection) && (detection.encoding.nil? || SUPPORTED_ENCODINGS.include?(detection.encoding)) &&
60
+ detection.confidence.is_a?(Float) && detection.confidence.finite? && detection.confidence.between?(0.0, 1.0) &&
61
+ detection.bom.is_a?(String) && detection.bom.encoding == Encoding::BINARY &&
62
+ [:lf, :crlf, :cr, :mixed, :none, nil].include?(detection.newline) &&
63
+ (detection.indent.nil? || detection.indent.is_a?(Indent)) &&
64
+ detection.binary == !!detection.binary
65
+ raise Error, "invalid detection"
66
+ end
67
+ raise Error, "text detection requires an encoding" if !detection.binary && detection.encoding.nil?
68
+ raise Error, "binary detection cannot specify text metadata" if detection.binary && (detection.encoding || !detection.bom.empty?)
69
+ if !detection.bom.empty? && !BOMS.include?([detection.bom, detection.encoding])
70
+ raise Error, "BOM does not match the detected encoding"
71
+ end
72
+ end
73
+ private_class_method :validate_detection!
74
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Menkar
4
+ VERSION = "0.1.0"
5
+ end
data/lib/menkar.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "menkar/version"
4
+
5
+ module Menkar
6
+ class Error < StandardError; end
7
+
8
+ module Value
9
+ module_function
10
+
11
+ def define(*members)
12
+ return Data.define(*members) if defined?(Data)
13
+
14
+ Struct.new(*members) do
15
+ members.each { |member| undef_method("#{member}=") }
16
+
17
+ def initialize(*values, **keywords)
18
+ if keywords.empty?
19
+ raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
20
+
21
+ super(*values)
22
+ else
23
+ raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
24
+
25
+ missing = self.class.members - keywords.keys
26
+ unknown = keywords.keys - self.class.members
27
+ raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
28
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
29
+
30
+ super(*self.class.members.map { |member| keywords.fetch(member) })
31
+ end
32
+ freeze
33
+ end
34
+
35
+ def with(**changes)
36
+ return self if changes.empty?
37
+
38
+ unknown = changes.keys - self.class.members
39
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
40
+
41
+ self.class.new(**to_h.merge(changes))
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ Detection = Value.define(:encoding, :confidence, :bom, :newline, :indent, :binary)
48
+ Indent = Value.define(:style, :width)
49
+ private_constant :Value
50
+
51
+ def self.newline_kind(text)
52
+ crlf = text.scan(/\r\n/).length
53
+ rest = text.gsub(/\r\n/, "")
54
+ kinds = []
55
+ kinds << :crlf if crlf.positive?
56
+ kinds << :lf if rest.include?("\n")
57
+ kinds << :cr if rest.include?("\r")
58
+ return :none if kinds.empty?
59
+
60
+ kinds.one? ? kinds.first : :mixed
61
+ end
62
+ private_class_method :newline_kind
63
+ end
64
+
65
+ require_relative "menkar/encoding_scorer"
66
+ require_relative "menkar/detector"
67
+ require_relative "menkar/transcoder"
68
+
69
+ module Menkar
70
+ private_constant :BOMS, :SUPPORTED_ENCODINGS, :NEWLINES, :EncodingScorer
71
+ end
data/sig/menkar.rbs ADDED
@@ -0,0 +1,39 @@
1
+ module Menkar
2
+ VERSION: String
3
+ DEFAULT_SAMPLE: Integer
4
+ MAX_SAMPLE: Integer
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ class Indent
10
+ attr_reader style: :tab | :space
11
+ attr_reader width: Integer?
12
+ def self.new: (:tab | :space style, Integer? width) -> Indent
13
+ | (style: :tab | :space, width: Integer?) -> Indent
14
+ def with: (?style: :tab | :space, ?width: Integer?) -> Indent
15
+ end
16
+
17
+ class Detection
18
+ attr_reader encoding: Encoding?
19
+ attr_reader confidence: Float
20
+ attr_reader bom: String
21
+ attr_reader newline: :lf | :crlf | :cr | :mixed | :none | nil
22
+ attr_reader indent: Indent?
23
+ attr_reader binary: bool
24
+ def self.new: (Encoding? encoding, Float confidence, String bom,
25
+ (:lf | :crlf | :cr | :mixed | :none | nil) newline, Indent? indent, bool binary) -> Detection
26
+ | (encoding: Encoding?, confidence: Float, bom: String,
27
+ newline: (:lf | :crlf | :cr | :mixed | :none | nil), indent: Indent?, binary: bool) -> Detection
28
+ def with: (?encoding: Encoding?, ?confidence: Float, ?bom: String,
29
+ ?newline: (:lf | :crlf | :cr | :mixed | :none | nil), ?indent: Indent?, ?binary: bool) -> Detection
30
+ end
31
+
32
+ def self.detect: (String bytes, ?hint: untyped, ?sample: Integer) -> Detection
33
+ def self.detect_file: (String path, ?sample: Integer) -> Detection
34
+ def self.binary?: (String bytes) -> bool
35
+ def self.decode: (String bytes, Detection detection) -> String
36
+ def self.encode: (String string, Detection detection) -> String
37
+ def self.roundtrip?: (String value, Detection detection) -> bool
38
+ def self.normalize_newlines: (String string, ?to: :lf | :crlf | :cr) -> [String, Symbol]
39
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: menkar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - t.yudai92@gmail.com
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - CHANGELOG.md
19
+ - LICENSE.txt
20
+ - README.md
21
+ - docs/adr/000-template.md
22
+ - docs/adr/001-strict-bounded-detection.md
23
+ - docs/adr/README.md
24
+ - lib/menkar.rb
25
+ - lib/menkar/detector.rb
26
+ - lib/menkar/encoding_scorer.rb
27
+ - lib/menkar/transcoder.rb
28
+ - lib/menkar/version.rb
29
+ - sig/menkar.rbs
30
+ homepage: https://github.com/noxdea/menkar
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ source_code_uri: https://github.com/noxdea/menkar
35
+ changelog_uri: https://github.com/noxdea/menkar/blob/main/CHANGELOG.md
36
+ allowed_push_host: https://rubygems.org
37
+ rubygems_mfa_required: 'true'
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.1'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 4.0.16
53
+ specification_version: 4
54
+ summary: Pure Ruby text encoding detection and normalization
55
+ test_files: []