cjk_index 0.2.0 → 1.0.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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../cjk_index"
6
+
7
+ module CJKIndex
8
+ # The command-line tool (exe/cjk_index) and the logic that the Rake task
9
+ # shares with it. Needs only Ruby's standard library; "csv" is loaded the
10
+ # first time a CSV file is read (see Records.csv).
11
+ #
12
+ # cjk_index build items.csv --ref objectid --fields title,creator --boost title=3 -o index.json
13
+ # cjk_index search index.json 鳥瞰図 --filter type=地図
14
+ # cjk_index runtime -o cjk-index.js
15
+ module CLI
16
+ # A mistake in the arguments or the input; run prints it and returns 2.
17
+ class Error < StandardError; end
18
+
19
+ USAGE = <<~TEXT
20
+ Usage: cjk_index COMMAND [options]
21
+
22
+ Commands:
23
+ build read records (CSV, JSON, JSON Lines) and write an index
24
+ search query an index file
25
+ runtime write the browser script
26
+
27
+ Run "cjk_index COMMAND --help" for the options of a command.
28
+ Options: -h, --help -v, --version
29
+ TEXT
30
+
31
+ FORMATS = %w[csv json jsonl].freeze
32
+
33
+ module_function
34
+
35
+ # Runs the tool and returns the exit status: 0 done, 1 failed (input could
36
+ # not be read), 2 wrong usage. Output goes to the given streams.
37
+ def run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
38
+ argv = argv.dup
39
+ command = argv.shift
40
+ case command
41
+ when nil
42
+ stderr.puts USAGE
43
+ return 2
44
+ when "-h", "--help", "help" then stdout.puts USAGE
45
+ when "-v", "--version", "version" then stdout.puts VERSION
46
+ when "build" then build_command(argv, stdin, stdout)
47
+ when "search" then search_command(argv, stdout)
48
+ when "runtime" then runtime_command(argv, stdout)
49
+ else raise Error, "unknown command #{command.inspect}\n\n#{USAGE}"
50
+ end
51
+ 0
52
+ rescue Error, OptionParser::ParseError, ArgumentError => e
53
+ stderr.puts "cjk_index: #{e.message.sub(/\Acjk_index: /, '')}"
54
+ 2
55
+ rescue SystemCallError, JSON::ParserError, IOError => e
56
+ stderr.puts "cjk_index: #{e.message}"
57
+ 1
58
+ end
59
+
60
+ # ---- build -------------------------------------------------------------
61
+
62
+ def build_command(argv, stdin, stdout)
63
+ opts = { fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES }
64
+ output = runtime = nil
65
+ parser = OptionParser.new do |o|
66
+ o.banner = "Usage: cjk_index build [FILE|-] [options]\n" \
67
+ "Records come from FILE or standard input: CSV, a JSON array, or JSON Lines."
68
+ o.on("--format FORMAT", FORMATS, "csv, json or jsonl (default: from the file name, or sniffed on stdin)") { |v| opts[:format] = v }
69
+ o.on("--ref COLUMN", "field that identifies a record (default: first field)") { |v| opts[:ref] = v }
70
+ o.on("--fields LIST", Array, "fields to index (default: all but the ref)") { |v| opts[:fields] = v }
71
+ o.on("--boost FIELD=N", "weight of a field, repeatable (default 1)") { |v| name, n = split_pair(v, "--boost"); opts[:boosts][name] = Float(n) }
72
+ o.on("--filter FIELD[=KIND[:SEP]]", "filter on a field: KIND value (default) or year; SEP splits several values in one cell") do |v|
73
+ name, spec = parse_filter(v)
74
+ opts[:filters][name] = spec
75
+ end
76
+ o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko") { |v| opts[:variants] = v }
77
+ o.on("-o", "--output PATH", "index file (default: standard output)") { |v| output = v }
78
+ o.on("--runtime PATH", "also write the browser script for these variants") { |v| runtime = v }
79
+ end
80
+ parser.parse!(argv)
81
+ raise Error, "too many arguments: #{argv.drop(1).join(' ')}" if argv.length > 1
82
+
83
+ source = argv.first
84
+ source = nil if source == "-"
85
+ format = opts.delete(:format) || format_of(source)
86
+ builder = build_index(**opts, records: read_records(source, format, stdin))
87
+ write_output(output, stdout, builder.to_json)
88
+ write_output(runtime, nil, Runtime.source(normalizer: builder.normalizer)) if runtime
89
+ end
90
+
91
+ # Builds an index from records (an Array of Hashes with string keys).
92
+ # Shared by the command and CJKIndex::RakeTask.
93
+ def build_index(records:, ref: nil, fields: nil, boosts: {}, filters: {}, variants: Normalizer::DEFAULT_TABLES)
94
+ raise Error, "no records" if records.empty?
95
+
96
+ keys = records.first.keys.map(&:to_s)
97
+ ref = (ref || keys.first).to_s
98
+ raise Error, "no field #{ref.inspect} in the records (fields: #{keys.join(', ')})" unless keys.include?(ref)
99
+
100
+ fields = Array(fields || (keys - [ref])).map(&:to_s)
101
+ raise Error, "no fields to index" if fields.empty?
102
+
103
+ missing = (fields + filters.keys.map(&:to_s) + boosts.keys.map(&:to_s)) - keys
104
+ raise Error, "no such field in the records: #{missing.uniq.join(', ')} (fields: #{keys.join(', ')})" unless missing.empty?
105
+
106
+ builder = Builder.new(fields: fields, boosts: boosts, filters: filters,
107
+ normalizer: Normalizer.new(tables: Array(variants).map(&:to_s)))
108
+ records.each do |record|
109
+ value = record[ref] || record[ref.to_sym]
110
+ builder.add(value, record) unless value.to_s.empty?
111
+ end
112
+ builder
113
+ end
114
+
115
+ # ---- reading records ---------------------------------------------------
116
+
117
+ def format_of(path)
118
+ case File.extname(path.to_s).downcase
119
+ when ".csv" then "csv"
120
+ when ".json" then "json"
121
+ when ".jsonl", ".ndjson" then "jsonl"
122
+ end
123
+ end
124
+
125
+ def read_records(path, format, stdin = $stdin)
126
+ text = path ? File.read(path, encoding: "UTF-8") : stdin.read.to_s.dup.force_encoding("UTF-8")
127
+ text = text.delete_prefix("")
128
+ format ||= text.lstrip.start_with?("[") ? "json" : "jsonl"
129
+ case format
130
+ when "csv" then csv(text)
131
+ when "json"
132
+ data = JSON.parse(text)
133
+ raise Error, "the JSON must be an array of objects" unless data.is_a?(Array) && data.all?(Hash)
134
+
135
+ data
136
+ else
137
+ text.each_line.reject { |l| l.strip.empty? }.map.with_index(1) do |line, n|
138
+ JSON.parse(line).tap { |r| raise Error, "line #{n} is not a JSON object" unless r.is_a?(Hash) }
139
+ end
140
+ end
141
+ end
142
+
143
+ # CSV is a bundled gem, not a default one, since Ruby 3.4: under Bundler
144
+ # it can only be loaded when the Gemfile lists it. The gem does not depend
145
+ # on it, so it is loaded here and a missing one is reported.
146
+ def csv(text)
147
+ begin
148
+ require "csv"
149
+ rescue LoadError
150
+ raise Error, "reading CSV needs the csv gem (a bundled gem since Ruby 3.4); " \
151
+ "run `gem install csv`, add `gem \"csv\"` to your Gemfile, or give JSON / JSON Lines instead"
152
+ end
153
+ CSV.parse(text, headers: true).map(&:to_h)
154
+ rescue CSV::MalformedCSVError => e
155
+ raise Error, "invalid CSV: #{e.message}"
156
+ end
157
+
158
+ # ---- search ------------------------------------------------------------
159
+
160
+ def search_command(argv, stdout)
161
+ filter = {}
162
+ limit = 10
163
+ json = false
164
+ parser = OptionParser.new do |o|
165
+ o.banner = "Usage: cjk_index search INDEX.json QUERY [options]"
166
+ o.on("--filter FIELD=VALUE", "keep matching records; repeat for several values or fields; a year filter takes 1900 or 1890..1910") do |v|
167
+ name, value = split_pair(v, "--filter")
168
+ (filter[name] ||= []) << value
169
+ end
170
+ o.on("--limit N", Integer, "results to show (default 10, 0 for all)") { |v| limit = v }
171
+ o.on("--json", "print JSON: [{ref, score, fields}]") { json = true }
172
+ end
173
+ parser.parse!(argv)
174
+ path, *words = argv
175
+ raise Error, "usage: cjk_index search INDEX.json QUERY" if path.nil? || (words.empty? && filter.empty?)
176
+
177
+ searcher = Searcher.load(path)
178
+ filter = filter.transform_values { |values| values.map { |v| filter_value(v) } }
179
+ filter = filter.transform_values { |values| values.length == 1 ? values.first : values }
180
+ results = searcher.search(words.join(" "), filter: filter.empty? ? nil : filter)
181
+ shown = limit.positive? ? results.first(limit) : results
182
+ if json
183
+ stdout.puts JSON.generate(shown)
184
+ else
185
+ shown.each { |r| stdout.puts format("%s\t%.3f\t%s", r[:ref], r[:score], r[:fields].join(",")) }
186
+ stdout.puts "(#{results.length} result#{'s' unless results.length == 1})" if shown.length < results.length
187
+ end
188
+ rescue Errno::ENOENT
189
+ raise Error, "no such file: #{path}"
190
+ end
191
+
192
+ # ---- runtime -----------------------------------------------------------
193
+
194
+ def runtime_command(argv, stdout)
195
+ variants = Normalizer::DEFAULT_TABLES
196
+ output = nil
197
+ OptionParser.new do |o|
198
+ o.banner = "Usage: cjk_index runtime [options]"
199
+ o.on("--variants LIST", Array, "variant tables: ja (default), zh-safe, zh, ko; must match the index") { |v| variants = v }
200
+ o.on("-o", "--output PATH", "script file (default: standard output)") { |v| output = v }
201
+ end.parse!(argv)
202
+ raise Error, "too many arguments: #{argv.join(' ')}" unless argv.empty?
203
+
204
+ write_output(output, stdout, Runtime.source(normalizer: Normalizer.new(tables: variants)))
205
+ end
206
+
207
+ # ---- helpers -----------------------------------------------------------
208
+
209
+ def split_pair(text, option)
210
+ name, value = text.split("=", 2)
211
+ raise Error, "#{option} takes NAME=VALUE, got #{text.inspect}" if name.to_s.empty? || value.nil?
212
+
213
+ [name, value]
214
+ end
215
+
216
+ # "type", "date=year", "subject=value:;"
217
+ def parse_filter(text)
218
+ name, spec = text.split("=", 2)
219
+ kind, separator = (spec || "value").split(":", 2)
220
+ raise Error, "--filter #{text.inspect}: kind must be value or year" unless Builder::FILTER_KINDS.include?(kind)
221
+
222
+ [name, separator ? { "kind" => kind, "separator" => separator } : kind]
223
+ end
224
+
225
+ def filter_value(text)
226
+ return text unless (m = text.match(/\A(\d*)\.\.(\d*)\z/)) && !(m[1].empty? && m[2].empty?)
227
+
228
+ { "min" => (m[1].empty? ? nil : m[1].to_i), "max" => (m[2].empty? ? nil : m[2].to_i) }.compact
229
+ end
230
+
231
+ def write_output(path, stdout, content)
232
+ if path
233
+ File.write(path, content)
234
+ else
235
+ stdout.write(content)
236
+ stdout.write("\n") unless content.end_with?("\n")
237
+ end
238
+ end
239
+ end
240
+ end
@@ -27,6 +27,10 @@ module CJKIndex
27
27
  #
28
28
  # Locks use flock, which is reliable on a local disk; do not share the file
29
29
  # over NFS.
30
+ #
31
+ # The index is always one file (format 4, as Builder#write). A split index
32
+ # (Builder#write_split) is not supported here: it is meant for static
33
+ # sites, written once per build.
30
34
  class IndexFile
31
35
  # Raised by update and write when another writer holds the lock too long.
32
36
  class LockTimeout < StandardError; end
@@ -25,8 +25,15 @@ module CJKIndex
25
25
  # date: year
26
26
  # - name: search
27
27
  # preset: collectionbuilder # reads CollectionBuilder's own config
28
+ # - name: texts
29
+ # collection: texts
30
+ # fields: [title, content]
31
+ # split: true # -> assets/cjk-index/texts/index.json and shards
32
+ # shard_size: 32000 # bytes per shard (optional)
28
33
  #
29
- # The browser script is written to <output>/cjk-index.js.
34
+ # The browser script is written to <output>/cjk-index.js. A split index is
35
+ # loaded with CJKIndex.load('<output>/<name>/index.json') and searched with
36
+ # searchAsync, which fetches only the shards a query needs (see Builder).
30
37
  module Jekyll
31
38
  DEFAULT_OUTPUT = "assets/cjk-index"
32
39
  RUNTIME_NAME = "cjk-index.js"
@@ -61,9 +68,18 @@ module CJKIndex
61
68
  site.static_files << GeneratedFile.new(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
62
69
  Array(config["indexes"]).each do |spec|
63
70
  builder = build(site, spec, normalizer)
64
- name = "#{spec.fetch('name')}.json"
65
- site.static_files << GeneratedFile.new(site, output, name, builder.to_json)
66
- ::Jekyll.logger.info "cjk_index:", "#{output}/#{name} (#{builder.refs.length} documents)"
71
+ if spec["split"]
72
+ dir = "#{output}/#{spec.fetch('name')}"
73
+ files = builder.each_split_file(shard_size: Integer(spec.fetch("shard_size", Builder::SHARD_SIZE)))
74
+ .map { |name, content| GeneratedFile.new(site, dir, name, content) }
75
+ site.static_files.concat(files)
76
+ ::Jekyll.logger.info "cjk_index:", "#{dir}/#{Builder::MANIFEST} (#{builder.refs.length} documents, " \
77
+ "#{files.length - 2} shards)"
78
+ else
79
+ name = "#{spec.fetch('name')}.json"
80
+ site.static_files << GeneratedFile.new(site, output, name, builder.to_json)
81
+ ::Jekyll.logger.info "cjk_index:", "#{output}/#{name} (#{builder.refs.length} documents)"
82
+ end
67
83
  end
68
84
  end
69
85
 
@@ -79,7 +95,7 @@ module CJKIndex
79
95
  docs = site.collections.fetch(spec["collection"]) do
80
96
  raise ::Jekyll::Errors::FatalException, "cjk_index: no collection #{spec['collection']}"
81
97
  end.docs
82
- docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields)) }
98
+ docs.each { |doc| builder.add(doc.url, document_fields(doc, builder.fields | builder.filters.keys)) }
83
99
  elsif spec["data"]
84
100
  ref = spec.fetch("ref", "id")
85
101
  Array(site.data[spec["data"]]).each do |row|
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "json"
5
+
3
6
  module CJKIndex
4
7
  # Folds text so that spelling variants a reader treats as "the same" compare
5
8
  # equal: NFKC (full-width -> half-width etc.), lowercase, katakana ->
@@ -9,18 +12,29 @@ module CJKIndex
9
12
  # The same rules run in the browser (see Runtime), so an index built here
10
13
  # and a query typed there are folded identically.
11
14
  #
12
- # Variant tables (data/*.tsv):
13
- # ja Japanese old forms -> modern forms (圖 -> 図). Default.
14
- # zh Traditional -> Simplified Chinese, from OpenCC. Opt in: it also
15
- # merges characters that are distinct in Japanese (後 and 后).
15
+ # Variant tables (data/*.tsv, regenerated by script/variant_tables.rb):
16
+ # ja Japanese old forms and glyph variants -> one form (圖 -> 図),
17
+ # from the Joyo and Jinmeiyo kanji lists and Unihan. Default.
18
+ # zh Traditional -> Simplified Chinese, from OpenCC. Opt in: it also
19
+ # merges characters that are distinct in Japanese (後 and 后).
20
+ # zh-safe The pairs of zh that are one-to-one both ways (圖 -> 图 but
21
+ # not 後 -> 后 or 發/髮 -> 发). Opt in.
22
+ # ko Old Hangul in the Hanyang Private Use Area -> standard jamo
23
+ # sequences (one character -> several). Opt in.
16
24
  # With several tables, characters linked by any table fold to one form,
17
25
  # preferring the target of the table listed first: with [ja, zh],
18
26
  # 圖, 図 and 图 all become 図.
27
+ #
28
+ # Table format: UTF-8 lines "variant<TAB>folded", optionally followed by
29
+ # another tab and a comment column; lines starting with "#" are comments.
30
+ # "folded" may be several characters.
19
31
  class Normalizer
20
32
  DATA_DIR = File.expand_path("../../data", __dir__)
21
33
  TABLES = {
22
34
  "ja" => "ja-kyujitai.tsv",
23
- "zh" => "zh-hant-hans.tsv"
35
+ "zh" => "zh-hant-hans.tsv",
36
+ "zh-safe" => "zh-hant-hans-safe.tsv",
37
+ "ko" => "ko-hanyang-pua.tsv"
24
38
  }.freeze
25
39
  DEFAULT_TABLES = %w[ja].freeze
26
40
 
@@ -38,31 +52,65 @@ module CJKIndex
38
52
  file = TABLES.fetch(name) { raise ArgumentError, "unknown variant table #{name.inspect} (#{TABLES.keys.join(', ')})" }
39
53
  File.readlines(File.join(DATA_DIR, file), chomp: true, encoding: "UTF-8")
40
54
  .reject { |line| line.empty? || line.start_with?("#") }
41
- .map { |line| line.split("\t", 2) }
55
+ .map { |line| line.split("\t")[0, 2] }
42
56
  end
43
57
 
44
- attr_reader :tables, :variants
58
+ attr_reader :tables, :variants, :digest
59
+
60
+ KATAKANA = "#{KATAKANA_FIRST.chr(Encoding::UTF_8)}-#{KATAKANA_LAST.chr(Encoding::UTF_8)}"
61
+ HIRAGANA = "#{(KATAKANA_FIRST - KANA_OFFSET).chr(Encoding::UTF_8)}-#{(KATAKANA_LAST - KANA_OFFSET).chr(Encoding::UTF_8)}"
45
62
 
46
63
  def initialize(tables: DEFAULT_TABLES)
47
64
  @tables = tables.map(&:to_s).uniq.freeze
48
65
  @variants = build_variants.freeze
66
+ keys = @variants.keys.select { |k| k.length == 1 }
67
+ @variant_chars = keys.empty? ? nil : Regexp.new("[#{keys.map { |k| format('\\u{%x}', k.ord) }.join}]")
68
+ # What the tables fold, not their names: an index built with an older
69
+ # "ja" table must not be read with a newer one.
70
+ @digest = Digest::SHA256.hexdigest(JSON.generate(@variants.sort)).slice(0, 16).freeze
71
+ end
72
+
73
+ # Raises unless data (an index) was built with the same tables as this
74
+ # normalizer. An index without a digest predates 1.0: it still loads, but
75
+ # it folded with older tables and should be rebuilt.
76
+ def check!(data)
77
+ if tables != data["variants"]
78
+ raise ArgumentError, "cjk_index: index built with variant tables #{data['variants'].inspect} " \
79
+ "but this normalizer folds with #{tables.inspect}; rebuild both together"
80
+ end
81
+ return if data["variant_digest"].nil? || data["variant_digest"] == digest
82
+
83
+ raise ArgumentError, "cjk_index: index built with other contents of the variant tables " \
84
+ "#{tables.inspect} (an older cjk_index?); rebuild it"
49
85
  end
50
86
 
87
+ # Character by character: katakana to hiragana, then the variant tables,
88
+ # then 々 to the (folded) character before it. Done as passes over the
89
+ # whole string, which gives the same result as one loop over the
90
+ # characters (the loop in runtime.js) and is several times faster in Ruby.
51
91
  def normalize(text)
52
92
  return "" if text.nil?
53
93
 
94
+ s = text.to_s.unicode_normalize(:nfkc).downcase
95
+ s = s.tr(KATAKANA, HIRAGANA)
96
+ # 々 repeats the folded character before it, which may be several
97
+ # characters (ko): fold one character at a time, as runtime.js does
98
+ return iterate(s) if s.include?(ITERATION_MARK)
99
+
100
+ @variant_chars ? s.gsub(@variant_chars, @variants) : s
101
+ end
102
+
103
+ private
104
+
105
+ def iterate(s)
54
106
  prev = nil
55
- text.to_s.unicode_normalize(:nfkc).downcase.each_char.map do |ch|
56
- code = ch.ord
57
- ch = (code - KANA_OFFSET).chr(Encoding::UTF_8) if code.between?(KATAKANA_FIRST, KATAKANA_LAST)
107
+ s.each_char.map do |ch|
58
108
  ch = @variants.fetch(ch, ch)
59
109
  ch = prev if ch == ITERATION_MARK && prev
60
110
  prev = ch
61
111
  end.join
62
112
  end
63
113
 
64
- private
65
-
66
114
  # Union-find over all pairs; each class folds to the target preferred by
67
115
  # the earliest table (lowest code point among equals, for determinism).
68
116
  def build_variants
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CJKIndex
4
+ # The compact encoding of a posting list in format 4, shared by Builder
5
+ # (writing) and Searcher (reading); runtime.js decodes it the same way.
6
+ #
7
+ # A list is a sequence of entries, ordered by document and field. Each
8
+ # entry is written as numbers:
9
+ #
10
+ # key gap, n, first position, gap to the next position, ...
11
+ #
12
+ # where key = document * number of fields + field, and the key gap is the
13
+ # difference to the key of the previous entry minus 1 (the first entry
14
+ # counts from -1). n is the number of positions. Each number is written in
15
+ # printable ASCII: the last digit in base 60 from the first 60 characters
16
+ # of ALPHABET, the digits before it in base 30 from the other 30, most
17
+ # significant first. 0..59 take one character, up to 1,799 two, up to
18
+ # 53,999 three. ALPHABET has neither " nor \, so a list is a JSON string
19
+ # without escapes.
20
+ module Postings
21
+ ALPHABET = ((0x23..0x7d).map(&:chr) - ["\\"]).join.freeze
22
+ LAST = 60
23
+ LEAD = 30
24
+ # The characters that end a number are the bytes below LEAD_BYTE
25
+ # (FINAL_BYTES in the notation of String#count).
26
+ LEAD_BYTE = ALPHABET.getbyte(LAST)
27
+ FINAL_BYTES = "#-[\\]-_"
28
+ # byte => digit; 0...60 ends a number, 60...90 leads.
29
+ DIGIT = Array.new(256, -1).tap { |t| ALPHABET.each_byte.with_index { |b, i| t[b] = i } }.freeze
30
+
31
+ def self.number_slow(value)
32
+ s = ALPHABET[value % LAST]
33
+ value /= LAST
34
+ while value.positive?
35
+ s = ALPHABET[LAST + (value % LEAD)] + s
36
+ value /= LEAD
37
+ end
38
+ s
39
+ end
40
+
41
+ # Every number of up to three characters, written once.
42
+ NUMBERS = Array.new(LAST * LEAD * LEAD) { |v| number_slow(v).freeze }.freeze
43
+
44
+ module_function
45
+
46
+ def number(value) = NUMBERS[value] || number_slow(value)
47
+
48
+ # Appends one entry to out. offsets are ascending positions.
49
+ def append(out, key_gap, offsets)
50
+ out << number(key_gap) << number(offsets.length)
51
+ last = 0
52
+ offsets.each do |pos|
53
+ out << number(pos - last)
54
+ last = pos
55
+ end
56
+ out
57
+ end
58
+
59
+ # All numbers of a list.
60
+ def numbers(str)
61
+ out = []
62
+ acc = 0
63
+ str.each_byte do |b|
64
+ d = DIGIT[b]
65
+ if d >= LAST then acc = (acc * LEAD) + d - LAST
66
+ else
67
+ out << ((acc * LAST) + d)
68
+ acc = 0
69
+ end
70
+ end
71
+ out
72
+ end
73
+
74
+ # Yields key, start, body and stop for each entry: its key, and the byte
75
+ # offsets where the entry starts, where its key gap ends and where it
76
+ # ends. Loops over bytes rather than numbers, so that the changes below
77
+ # can copy the rest of a list as it is.
78
+ def each_entry(str)
79
+ return enum_for(:each_entry, str) unless block_given?
80
+
81
+ key = -1
82
+ i = 0
83
+ len = str.bytesize
84
+ while i < len
85
+ start = i
86
+ acc = 0
87
+ while (d = DIGIT[str.getbyte(i)]) >= LAST
88
+ acc = (acc * LEAD) + d - LAST
89
+ i += 1
90
+ end
91
+ key += (acc * LAST) + d + 1
92
+ i += 1
93
+ body = i
94
+ acc = 0
95
+ while (d = DIGIT[str.getbyte(i)]) >= LAST
96
+ acc = (acc * LEAD) + d - LAST
97
+ i += 1
98
+ end
99
+ n = (acc * LAST) + d
100
+ i += 1
101
+ # Skip n numbers: each ends with a byte below LEAD_BYTE. Many at a
102
+ # time are counted by String#count, which runs in C.
103
+ while n > 8
104
+ t = str.byteslice(i, n).count(FINAL_BYTES)
105
+ i += n
106
+ n -= t
107
+ end
108
+ while n.positive?
109
+ n -= 1 if str.getbyte(i) < LEAD_BYTE
110
+ i += 1
111
+ end
112
+ yield key, start, body, i
113
+ end
114
+ end
115
+
116
+ # Key of the last entry, -1 for an empty list.
117
+ def last_key(str)
118
+ key = -1
119
+ each_entry(str) { |k| key = k }
120
+ key
121
+ end
122
+
123
+ # Each key of a list, in order.
124
+ def each_key(str)
125
+ return enum_for(:each_key, str) unless block_given?
126
+
127
+ each_entry(str) { |key| yield key }
128
+ end
129
+
130
+ # The list without the entries whose keys are in range (consecutive).
131
+ def delete(str, range)
132
+ before = -1
133
+ from = nil
134
+ each_entry(str) do |key, start, body|
135
+ if range.cover?(key)
136
+ from ||= start
137
+ elsif from
138
+ return str.byteslice(0, from) + number(key - before - 1) + str.byteslice(body..)
139
+ elsif key > range.end
140
+ return str
141
+ else
142
+ before = key
143
+ end
144
+ end
145
+ from ? str.byteslice(0, from) : str
146
+ end
147
+
148
+ # The list with an entry for key (not yet in it) and offsets inserted in order.
149
+ def insert(str, key, offsets)
150
+ before = -1
151
+ each_entry(str) do |k, start, body|
152
+ if k > key
153
+ entry = append(+"", key - before - 1, offsets)
154
+ return str.byteslice(0, start) + entry + number(k - key - 1) + str.byteslice(body..)
155
+ end
156
+ before = k
157
+ end
158
+ append(+str, key - before - 1, offsets)
159
+ end
160
+
161
+ # The list with each key's document replaced by numbering[document], which
162
+ # keeps the order (removed documents have no entries).
163
+ def renumber(str, fields, numbering)
164
+ out = +""
165
+ old_before = -1
166
+ new_before = -1
167
+ copied = 0 # str up to here is in out or to be copied as it is
168
+ each_entry(str) do |key, start, body|
169
+ new_key = (numbering[key / fields] * fields) + (key % fields)
170
+ if new_key - new_before != key - old_before
171
+ out << str.byteslice(copied, start - copied) << number(new_key - new_before - 1)
172
+ copied = body
173
+ end
174
+ old_before = key
175
+ new_before = new_key
176
+ end
177
+ copied.zero? ? str : out << str.byteslice(copied..)
178
+ end
179
+
180
+ # The flat list of format 3, [doc, field, n, pos, ..., doc, ...].
181
+ def decode(str, fields)
182
+ nums = numbers(str)
183
+ out = []
184
+ key = -1
185
+ i = 0
186
+ while i < nums.length
187
+ key += nums[i] + 1
188
+ n = nums[i + 1]
189
+ out << (key / fields) << (key % fields) << n
190
+ pos = 0
191
+ j = i + 2
192
+ stop = j + n
193
+ while j < stop
194
+ pos += nums[j]
195
+ out << pos
196
+ j += 1
197
+ end
198
+ i = stop
199
+ end
200
+ out
201
+ end
202
+
203
+ # A flat list of format 2 or 3 in this encoding.
204
+ def encode(flat, fields)
205
+ out = +""
206
+ last = -1
207
+ i = 0
208
+ while i < flat.length
209
+ key = (flat[i] * fields) + flat[i + 1]
210
+ n = flat[i + 2]
211
+ append(out, key - last - 1, flat[i + 3, n])
212
+ last = key
213
+ i += 3 + n
214
+ end
215
+ out
216
+ end
217
+ end
218
+ end