denebola 0.1.0 → 0.2.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 +7 -0
- data/README.md +140 -35
- data/lib/denebola/lazy_rope.rb +572 -0
- data/lib/denebola/version.rb +1 -1
- data/lib/denebola.rb +1 -0
- data/sig/denebola.rbs +37 -0
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 605dec1949038f3bf81ca5b49d875231c515fdc656933467f2d34d1b1411da7e
|
|
4
|
+
data.tar.gz: 856f7ba61ad06df9f280850a4912259d1a76556cf32e0e5e8b8c78e6cec264e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02af176392f474c79428f0837f7ea44ed7198a25a56a250d6119b80ddc9a9bf6f5e5eba7e020417c3c3b922ec65912b075050e6224993b73e78f4da1c8cf1d79
|
|
7
|
+
data.tar.gz: 2d69fa29d55fb1970b30da016df0cc4730985ec286aaaa2048c08f17472f38e3904d37cefaa75961b6f3e476e1e86e3bd45fa1223dbe7101a2802dd480a2e8ed
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
- Add `LazyRope` for bounded-memory access to very large UTF-8 files.
|
|
6
|
+
- Build line, character, and UTF-16 indexes incrementally.
|
|
7
|
+
- Keep edits as in-memory overlays while untouched bytes remain file-backed.
|
|
8
|
+
- Detect files changed or replaced after opening.
|
|
9
|
+
|
|
3
10
|
## 0.1.0 — 2026-09-10
|
|
4
11
|
|
|
5
12
|
- Initial release.
|
data/README.md
CHANGED
|
@@ -1,12 +1,66 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Denebola</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Persistent summary B+ trees and Unicode text ropes in pure Ruby</strong>
|
|
5
|
+
</p>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/denebola"><img src="https://img.shields.io/gem/v/denebola.svg?colorB=319e8c" alt="Gem version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/denebola"><img src="https://img.shields.io/gem/dt/denebola.svg" alt="Downloads"></a>
|
|
10
|
+
<a href="https://github.com/noxdea/denebola/actions/workflows/main.yml"><img src="https://github.com/noxdea/denebola/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby version">
|
|
12
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
13
|
+
</p>
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
19
|
+
<a href="#text-rope">Text Rope</a> ·
|
|
20
|
+
<a href="#generic-summary-tree">Summary Tree</a> ·
|
|
21
|
+
<a href="#benchmarks">Benchmarks</a>
|
|
22
|
+
</p>
|
|
8
23
|
|
|
9
|
-
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Denebola is a library for immutable, structurally shared sequences. It provides a generic summary B+ tree and a Unicode-aware text rope built on it. Every edit returns a new value while reusing untouched subtrees, so retaining a snapshot is an ordinary assignment.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- Persistent text editing with structural sharing
|
|
31
|
+
- Bounded-memory, file-backed editing for multi-gigabyte text
|
|
32
|
+
- UTF-8 byte, Unicode codepoint, UTF-16, and line-based indexing
|
|
33
|
+
- Batched edits and explicit anchor transformation
|
|
34
|
+
- LF, CRLF, CR, U+2028, and U+2029 line break support
|
|
35
|
+
- Generic summary B+ tree with dimension-based cursors
|
|
36
|
+
- Pure Ruby 3.1+ with no runtime dependencies
|
|
37
|
+
- RBS signatures for the public API
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Add Denebola to your Gemfile:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
gem "denebola"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then install:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bundle install
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or install it directly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
gem install denebola
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Requirements
|
|
60
|
+
|
|
61
|
+
- Ruby 3.1 or later
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
10
64
|
|
|
11
65
|
```ruby
|
|
12
66
|
require "denebola"
|
|
@@ -14,32 +68,56 @@ require "denebola"
|
|
|
14
68
|
rope = Denebola::Rope.new("hello\nworld")
|
|
15
69
|
snapshot = rope
|
|
16
70
|
rope = rope.insert(5, ", there")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
rope.
|
|
20
|
-
|
|
21
|
-
rope.line_count # => 2
|
|
22
|
-
rope.byteslice(0, 5).to_s # => "hello"
|
|
23
|
-
rope = rope.delete(0...5)
|
|
24
|
-
rope = rope.replace(0...7, "goodbye")
|
|
25
|
-
rope.each_chunk { |text| puts text } # Frozen strings, without flattening
|
|
71
|
+
|
|
72
|
+
rope.line(0) # => "hello, there"
|
|
73
|
+
rope.to_s # => "hello, there\nworld"
|
|
74
|
+
snapshot.to_s # => "hello\nworld"
|
|
26
75
|
```
|
|
27
76
|
|
|
28
|
-
|
|
77
|
+
For files that should not be copied into memory, open a `LazyRope`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
text = Denebola::LazyRope.open("server.log")
|
|
81
|
+
text.line(1_000_000) # indexes only as far as needed
|
|
82
|
+
text.line_count # estimate until the index reaches EOF
|
|
83
|
+
text.line_count(exact: true) # force an exact count
|
|
84
|
+
text.edit(0...5, "INFO:") # untouched bytes remain file-backed
|
|
85
|
+
text.materialize(0...1024) # an ordinary editable Rope
|
|
86
|
+
text.close
|
|
87
|
+
```
|
|
29
88
|
|
|
30
|
-
`
|
|
89
|
+
`LazyRope` reads 1 MiB chunks by default and keeps eight chunks in an LRU cache. `byteslice`, line access, byte/codepoint positions, UTF-16 positions, and anchors follow `Rope` semantics. `edit`, `insert`, `delete`, and `replace` mutate the open view and store only replacement text in memory; call `materialize` when an immutable `Rope` snapshot is needed. A file changed, removed, or replaced after opening raises `Denebola::Error`. `to_s` and `materialize` without a range intentionally load the complete logical file.
|
|
90
|
+
|
|
91
|
+
## Text Rope
|
|
92
|
+
|
|
93
|
+
### Editing
|
|
31
94
|
|
|
32
95
|
```ruby
|
|
33
96
|
rope = Denebola::Rope.new("hello\nworld")
|
|
34
|
-
|
|
97
|
+
|
|
98
|
+
rope.bytesize # => 11
|
|
99
|
+
rope.length # => 11 Unicode codepoints
|
|
100
|
+
rope.line_count # => 2
|
|
101
|
+
rope.byteslice(0, 5).to_s # => "hello"
|
|
102
|
+
rope.delete(0...5).to_s # => "\nworld"
|
|
103
|
+
rope.replace(0...5, "Hi").to_s # => "Hi\nworld"
|
|
104
|
+
rope.each_chunk { |text| puts text } # Frozen strings, without flattening
|
|
105
|
+
|
|
106
|
+
rope.apply_edits([[0...5, "Hi"], [6...11, "Ruby"]]).to_s
|
|
107
|
+
# => "Hi\nRuby"
|
|
35
108
|
```
|
|
36
109
|
|
|
37
|
-
|
|
110
|
+
Ranges passed to `replace`, `apply_edits`, and `byteslice` address bytes in UTF-8. Bounds and codepoint boundaries are checked. Inclusive, exclusive, beginless, and endless Ruby ranges are accepted; invalid encodings, split codepoints, and overlapping batch edits raise exceptions.
|
|
111
|
+
|
|
112
|
+
`apply_edits` applies nonoverlapping edits against one original snapshot and sorts them into source order. Adjacent ranges are allowed. Input strings are copied or shared safely with Ruby's copy-on-write strings, so later mutation of the source cannot alter a rope.
|
|
38
113
|
|
|
39
|
-
|
|
114
|
+
Chunks preserve extended grapheme clusters when building or joining text. A single unusually long grapheme may exceed the configured chunk size; explicit byte slices and edits may operate between codepoints inside a grapheme.
|
|
115
|
+
|
|
116
|
+
### Lines and Positions
|
|
40
117
|
|
|
41
118
|
```ruby
|
|
42
119
|
rope = Denebola::Rope.new("😀\n日本")
|
|
120
|
+
|
|
43
121
|
rope.point_at(8) # => Point(row: 1, column: 1)
|
|
44
122
|
rope.offset_at(Denebola::Point.new(1, 1)) # => 8
|
|
45
123
|
rope.line_start(1) # => 5
|
|
@@ -49,28 +127,32 @@ rope.utf16_point_at(4) # => Point(row: 0, column: 2)
|
|
|
49
127
|
rope.offset_at_utf16_point(Denebola::Point.new(0, 2)) # => 4
|
|
50
128
|
```
|
|
51
129
|
|
|
52
|
-
`Point` accepts positional or keyword `row` and `column`. Unqualified offsets are UTF-8 byte offsets. Normal columns count Unicode codepoints, not screen cells or graphemes.
|
|
130
|
+
`Point` accepts positional or keyword `row` and `column`. Unqualified offsets are UTF-8 byte offsets. Normal columns count Unicode codepoints, not screen cells or graphemes. UTF-16 methods count code units and reject offsets inside a surrogate pair. `offset_at` and `offset_at_utf16_point` require a column within the line's content.
|
|
131
|
+
|
|
132
|
+
`line(row)` omits the terminator. Rows are zero-based; empty text and a final empty row after a terminator each count as a line. A byte position between CR and LF normalizes to the following row, column zero; converting that point back returns the position after LF.
|
|
53
133
|
|
|
54
|
-
`rope.summary` exposes `bytesize`, `length`, `utf16_length`, `break_count`, `longest_row` (earliest row with maximum width), `longest_row_length`, `first_line_length`, and `last_line_length`. `TextSummary.zero` is the identity and `summary + other_summary` combines concatenated text, including CRLF across a boundary.
|
|
134
|
+
`rope.summary` exposes `bytesize`, `length`, `utf16_length`, `break_count`, `longest_row` (the earliest row with maximum width), `longest_row_length`, `first_line_length`, and `last_line_length`. `TextSummary.zero` is the identity, and `summary + other_summary` combines concatenated text, including CRLF across a boundary.
|
|
55
135
|
|
|
56
|
-
|
|
136
|
+
### Anchors
|
|
57
137
|
|
|
58
|
-
Anchors transform explicitly using the same finite byte ranges as an edit, without retaining the
|
|
138
|
+
Anchors transform explicitly using the same finite byte ranges as an edit, without retaining the edit history:
|
|
59
139
|
|
|
60
140
|
```ruby
|
|
61
141
|
rope = Denebola::Rope.new("hello")
|
|
62
142
|
anchor = rope.anchor(2, bias: :right)
|
|
63
143
|
edits = [[2...2, "XYZ"]]
|
|
144
|
+
|
|
64
145
|
anchor = anchor.transform(edits)
|
|
65
146
|
rope = rope.apply_edits(edits)
|
|
147
|
+
|
|
66
148
|
anchor.offset # => 5
|
|
67
149
|
```
|
|
68
150
|
|
|
69
|
-
`:left` keeps an anchor before text inserted at its position; `:right` keeps it after. Anchors covered by a replacement collapse to the corresponding side of the replacement. Offsets after an edit move by its byte-length delta.
|
|
151
|
+
`:left` keeps an anchor before text inserted at its position; `:right` keeps it after. Anchors covered by a replacement collapse to the corresponding side of the replacement. Offsets after an edit move by its byte-length delta. Transformation returns a new anchor; it does not mutate the original or automatically observe a rope.
|
|
70
152
|
|
|
71
|
-
## Generic
|
|
153
|
+
## Generic Summary Tree
|
|
72
154
|
|
|
73
|
-
Items expose `summary`. The summary class supplies `.zero` and `#+`, with
|
|
155
|
+
Items expose `summary`. The summary class supplies `.zero` and `#+`, with associative addition and an identity. Items and their summaries must be immutable. A dimension is a summary attribute name, a callable, or an object with `from_summary(summary)`; its projection must be monotone along the sequence.
|
|
74
156
|
|
|
75
157
|
```ruby
|
|
76
158
|
Weight = Struct.new(:weight) do
|
|
@@ -79,27 +161,35 @@ Weight = Struct.new(:weight) do
|
|
|
79
161
|
def summary = self
|
|
80
162
|
end
|
|
81
163
|
|
|
82
|
-
tree = Denebola::Tree.new(
|
|
164
|
+
tree = Denebola::Tree.new(
|
|
165
|
+
[Weight.new(2).freeze, Weight.new(3).freeze],
|
|
166
|
+
summary: Weight
|
|
167
|
+
)
|
|
83
168
|
tree = tree.push(Weight.new(5).freeze)
|
|
84
169
|
cursor = tree.cursor(:weight).seek(2)
|
|
170
|
+
|
|
85
171
|
cursor.item.weight # => 3
|
|
86
|
-
cursor.summary.weight # => 2
|
|
172
|
+
cursor.summary.weight # => 2, the summary before the current item
|
|
87
173
|
cursor.next.weight # => 5
|
|
88
174
|
cursor.prev.weight # => 3
|
|
89
175
|
cursor.seek(2, bias: :left).item.weight # => 2
|
|
90
176
|
```
|
|
91
177
|
|
|
92
|
-
`Tree.from(items, summary: ...)` and `Tree.new(items, summary: ...)` bulk-build an
|
|
178
|
+
`Tree.from(items, summary: ...)` and `Tree.new(items, summary: ...)` bulk-build an ordered sequence. `append` joins another compatible tree or enumerable. `tree[index]`, `slice(index, count)`, and `split_at(index)` use item indexes. `replace_at(index, items)` replaces one item, or appends when `index == size`. Every update returns a new tree.
|
|
93
179
|
|
|
94
180
|
`cursor.seek(value)` chooses the item whose ending dimension exceeds the target; `bias: :left` also includes equality. At the end, `item` is `nil`, `index == tree.size`, and `summary` is the whole summary. `cursor.read_until(value)` returns complete items up to the target boundary and advances the cursor; it does not split an item. `next` and `prev` return the newly selected item or `nil`.
|
|
95
181
|
|
|
96
|
-
Text dimensions are
|
|
182
|
+
Text dimensions are available as `Denebola::Dimensions::BYTES`, `CHARACTERS`, `UTF16`, and `LINE_BREAKS`. B+ nodes hold cumulative summaries and item counts; navigation uses binary search. Editing copies touched chunks and ancestor paths, while splitting and joining preserve occupancy and equal leaf depth. Returning a string or a long line necessarily costs at least its output size.
|
|
183
|
+
|
|
184
|
+
`check_invariants!` checks occupancy, frozen nodes, depth, prefix counts, and summaries. It is intended for tests.
|
|
97
185
|
|
|
98
|
-
## Benchmarks
|
|
186
|
+
## Benchmarks
|
|
99
187
|
|
|
100
|
-
|
|
188
|
+
```bash
|
|
189
|
+
bundle exec rake bench
|
|
190
|
+
```
|
|
101
191
|
|
|
102
|
-
|
|
192
|
+
The benchmark compares fanouts 8/16/32/64 and chunk sizes 256/512/1024/2048 before exercising a 1,000,000-line ASCII document (11,000,000 bytes). Timings are five-batch medians after warmup. The defaults are fanout **16** and chunk size **1024 bytes**: smaller chunks improve some edits but allocate more nodes, while these defaults meet the edit and retained-memory budgets together. Override them with `Rope.new(text, branching: 8, chunk_size: 512)`.
|
|
103
193
|
|
|
104
194
|
Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
|
105
195
|
|
|
@@ -113,8 +203,23 @@ Measured 2026-09-09 on arm64 macOS, Ruby 4.0.2 with YJIT:
|
|
|
113
203
|
| Slice 1 KB | 6.79 µs | 20 µs |
|
|
114
204
|
| Retained Ruby object memory | 33.84 MiB | 40 MiB |
|
|
115
205
|
|
|
116
|
-
Performance depends on document content, Ruby version, and hardware. Memory is measured with `ObjectSpace` after releasing the input and collecting garbage; it is not process RSS. `rake bench:assert` uses
|
|
206
|
+
Performance depends on document content, Ruby version, and hardware. Memory is measured with `ObjectSpace` after releasing the input and collecting garbage; it is not process RSS. `rake bench:assert` uses wider timing ceilings for shared CI hardware while retaining the 40 MiB memory ceiling.
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
bundle install
|
|
212
|
+
bundle exec rake test
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The default suite includes 100,000 deterministic random Unicode edits compared with Ruby `String`, 5,000 summary monoid cases, immutable snapshots, split/join occupancy checks, all supported newline types, surrogate boundaries, and retained node counts after 10,000 edits. `bundle exec rake test:oracle` runs the property tests alone, and `ruby tools/check_isolation.rb` checks runtime independence.
|
|
216
|
+
|
|
217
|
+
CI tests Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux, macOS, and Windows. See the [RBS signatures](sig/denebola.rbs) for the public API and the [changelog](CHANGELOG.md) for release history.
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
Bug reports and pull requests are welcome at https://github.com/noxdea/denebola.
|
|
117
222
|
|
|
118
223
|
## License
|
|
119
224
|
|
|
120
|
-
[MIT](LICENSE.txt).
|
|
225
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Denebola
|
|
4
|
+
# A file-backed text rope. File pages and indexes are populated on demand;
|
|
5
|
+
# edits replace only the affected byte ranges with ordinary Rope values.
|
|
6
|
+
class LazyRope
|
|
7
|
+
DEFAULT_CHUNK_SIZE = 1_048_576
|
|
8
|
+
DEFAULT_CACHE_CHUNKS = 8
|
|
9
|
+
FilePiece = Struct.new(:offset, :bytesize, keyword_init: true)
|
|
10
|
+
TextPiece = Struct.new(:rope, keyword_init: true) do
|
|
11
|
+
def bytesize = rope.bytesize
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :chunk_size, :cached_bytes
|
|
15
|
+
|
|
16
|
+
def self.open(path, chunk_size: DEFAULT_CHUNK_SIZE, encoding: Encoding::UTF_8, cache_chunks: DEFAULT_CACHE_CHUNKS)
|
|
17
|
+
new(path, chunk_size: chunk_size, encoding: encoding, cache_chunks: cache_chunks)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(path, chunk_size: DEFAULT_CHUNK_SIZE, encoding: Encoding::UTF_8, cache_chunks: DEFAULT_CACHE_CHUNKS)
|
|
21
|
+
raise ArgumentError, "chunk_size must be an integer >= 4" unless chunk_size.is_a?(Integer) && chunk_size >= 4
|
|
22
|
+
raise ArgumentError, "cache_chunks must be a positive integer" unless cache_chunks.is_a?(Integer) && cache_chunks.positive?
|
|
23
|
+
raise ArgumentError, "only UTF-8 files are supported" unless encoding == Encoding::UTF_8
|
|
24
|
+
|
|
25
|
+
flags = File::RDONLY | File::BINARY
|
|
26
|
+
flags |= File::SHARE_DELETE if defined?(File::SHARE_DELETE)
|
|
27
|
+
@file = File.open(path, flags)
|
|
28
|
+
@path = File.expand_path(path)
|
|
29
|
+
@chunk_size = chunk_size
|
|
30
|
+
@cache_chunks = cache_chunks
|
|
31
|
+
@cache = {}
|
|
32
|
+
@cached_bytes = 0
|
|
33
|
+
@positioned_reads = true
|
|
34
|
+
@read_lock = Mutex.new
|
|
35
|
+
@stamp = file_stamp(@file.stat)
|
|
36
|
+
@path_stamp = file_stamp(File.stat(@path))
|
|
37
|
+
@origin = @file.size >= 3 && raw_read(0, 3) == "\xEF\xBB\xBF".b ? 3 : 0
|
|
38
|
+
@source_bytesize = @file.size - @origin
|
|
39
|
+
@pieces = @source_bytesize.zero? ? [] : [FilePiece.new(offset: 0, bytesize: @source_bytesize).freeze]
|
|
40
|
+
rebuild_piece_ends
|
|
41
|
+
reset_index
|
|
42
|
+
rescue StandardError
|
|
43
|
+
@file&.close
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lazy? = true
|
|
48
|
+
def closed? = @file.closed?
|
|
49
|
+
|
|
50
|
+
def bytesize
|
|
51
|
+
ensure_unchanged!
|
|
52
|
+
@piece_ends.last || 0
|
|
53
|
+
end
|
|
54
|
+
def empty? = bytesize.zero?
|
|
55
|
+
|
|
56
|
+
def length
|
|
57
|
+
index_to_eof
|
|
58
|
+
@indexed_characters
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def size = length
|
|
62
|
+
|
|
63
|
+
def utf16_length
|
|
64
|
+
index_to_eof
|
|
65
|
+
@indexed_utf16
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Until exact indexing is requested, return an estimate based on the
|
|
69
|
+
# already scanned prefix. The estimate never reports fewer known lines.
|
|
70
|
+
def line_count(exact: false)
|
|
71
|
+
index_to_eof if exact
|
|
72
|
+
return packed_line_count if @index_complete
|
|
73
|
+
scan_index if @indexed_offset.zero? && bytesize.positive?
|
|
74
|
+
|
|
75
|
+
known_lines = packed_line_count + (@pending_cr ? 1 : 0)
|
|
76
|
+
breaks = known_lines - 1
|
|
77
|
+
return 1 if breaks.zero? || @indexed_offset.zero?
|
|
78
|
+
|
|
79
|
+
[known_lines, (breaks * bytesize.fdiv(@indexed_offset)).round + 1].max
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def line_start(row)
|
|
83
|
+
raise RangeError, "line out of bounds" unless row.is_a?(Integer) && row >= 0
|
|
84
|
+
index_until_line(row)
|
|
85
|
+
raise RangeError, "line out of bounds" if row >= packed_line_count
|
|
86
|
+
|
|
87
|
+
unpack_line_start(row)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def line(row)
|
|
91
|
+
start = line_start(row)
|
|
92
|
+
index_until_line(row + 1)
|
|
93
|
+
ending = row + 1 < packed_line_count ? unpack_line_start(row + 1) : bytesize
|
|
94
|
+
read_bytes(start, ending - start).force_encoding(Encoding::UTF_8).sub(/(?:\r\n|[\r\n\u2028\u2029])\z/, "")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def byteslice(offset, length = nil)
|
|
98
|
+
start, finish = slice_bounds(offset, length)
|
|
99
|
+
Rope.new(read_bytes(start, finish - start).force_encoding(Encoding::UTF_8))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def each_chunk
|
|
103
|
+
return enum_for(__method__) unless block_given?
|
|
104
|
+
|
|
105
|
+
offset = 0
|
|
106
|
+
total = bytesize
|
|
107
|
+
while offset < total
|
|
108
|
+
raw = read_bytes(offset, [chunk_size, total - offset].min)
|
|
109
|
+
complete = complete_utf8_length(raw)
|
|
110
|
+
if complete.zero?
|
|
111
|
+
raw = read_bytes(offset, [chunk_size + 3, total - offset].min)
|
|
112
|
+
complete = complete_utf8_length(raw)
|
|
113
|
+
end
|
|
114
|
+
text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
|
|
115
|
+
raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
|
|
116
|
+
yield text
|
|
117
|
+
offset += complete
|
|
118
|
+
end
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def materialize(range = nil)
|
|
123
|
+
range ? byteslice(range) : Rope.new(to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_s
|
|
127
|
+
text = String.new(capacity: bytesize, encoding: Encoding::UTF_8)
|
|
128
|
+
each_chunk { |chunk| text << chunk }
|
|
129
|
+
text
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Mutates this file-backed view and keeps untouched ranges file-backed.
|
|
133
|
+
def edit(range, text)
|
|
134
|
+
replacement = normalize_text(text)
|
|
135
|
+
start, finish = byte_bounds(range)
|
|
136
|
+
validate_offset(start)
|
|
137
|
+
validate_offset(finish)
|
|
138
|
+
|
|
139
|
+
updated = pieces_for(0, start)
|
|
140
|
+
updated << TextPiece.new(rope: Rope.new(replacement)).freeze unless replacement.empty?
|
|
141
|
+
updated.concat(pieces_for(finish, bytesize))
|
|
142
|
+
@pieces = coalesce(updated)
|
|
143
|
+
rebuild_piece_ends
|
|
144
|
+
invalidate_index_from(start)
|
|
145
|
+
self
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def insert(offset, text) = edit(offset...offset, text)
|
|
149
|
+
def delete(range) = edit(range, "")
|
|
150
|
+
def replace(range, text) = edit(range, text)
|
|
151
|
+
|
|
152
|
+
def point_at(byte_offset)
|
|
153
|
+
validate_offset(byte_offset)
|
|
154
|
+
index_to([byte_offset + 1, bytesize].min)
|
|
155
|
+
if byte_offset.positive? && byte_offset < bytesize && read_bytes(byte_offset - 1, 2) == "\r\n"
|
|
156
|
+
row = line_index_at(byte_offset + 1)
|
|
157
|
+
return Point.new(row, 0)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
row = line_index_at(byte_offset)
|
|
161
|
+
start = unpack_line_start(row)
|
|
162
|
+
Point.new(row, read_bytes(start, byte_offset - start).force_encoding(Encoding::UTF_8).length)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def offset_at(point)
|
|
166
|
+
start = line_start(point.row)
|
|
167
|
+
content = line(point.row)
|
|
168
|
+
raise RangeError, "column out of bounds" unless point.column.is_a?(Integer) && point.column.between?(0, content.length)
|
|
169
|
+
|
|
170
|
+
start + content.each_char.take(point.column).join.bytesize
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def utf16_offset_at(byte_offset)
|
|
174
|
+
validate_offset(byte_offset)
|
|
175
|
+
prefix_dimensions(byte_offset).last
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def offset_at_utf16(utf16_offset)
|
|
179
|
+
raise RangeError, "UTF-16 offset out of bounds" unless utf16_offset.is_a?(Integer) && utf16_offset >= 0
|
|
180
|
+
index_to_eof
|
|
181
|
+
raise RangeError, "UTF-16 offset out of bounds" if utf16_offset > @indexed_utf16
|
|
182
|
+
|
|
183
|
+
checkpoint = @checkpoints.bsearch { |entry| entry[2] > utf16_offset }
|
|
184
|
+
checkpoint = checkpoint ? @checkpoints[@checkpoints.index(checkpoint) - 1] : @checkpoints.last
|
|
185
|
+
offset, _characters, units = checkpoint
|
|
186
|
+
return offset if units == utf16_offset
|
|
187
|
+
|
|
188
|
+
each_codepoint_from(offset) do |codepoint, byte_start, _byte_finish|
|
|
189
|
+
width = codepoint > 0xFFFF ? 2 : 1
|
|
190
|
+
raise RangeError, "UTF-16 offset splits a surrogate pair" if units + width > utf16_offset
|
|
191
|
+
units += width
|
|
192
|
+
return _byte_finish if units == utf16_offset
|
|
193
|
+
break if units > utf16_offset
|
|
194
|
+
end
|
|
195
|
+
raise RangeError, "UTF-16 offset out of bounds"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def utf16_point_at(byte_offset)
|
|
199
|
+
point = point_at(byte_offset)
|
|
200
|
+
start = line_start(point.row)
|
|
201
|
+
Point.new(point.row, [utf16_offset_at(byte_offset) - utf16_offset_at(start), 0].max)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def offset_at_utf16_point(point)
|
|
205
|
+
start = line_start(point.row)
|
|
206
|
+
content = line(point.row)
|
|
207
|
+
units = 0
|
|
208
|
+
bytes = 0
|
|
209
|
+
content.each_codepoint do |codepoint|
|
|
210
|
+
return start + bytes if units == point.column
|
|
211
|
+
width = codepoint > 0xFFFF ? 2 : 1
|
|
212
|
+
raise RangeError, "UTF-16 column splits a surrogate pair" if units + width > point.column
|
|
213
|
+
units += width
|
|
214
|
+
bytes += codepoint.chr(Encoding::UTF_8).bytesize
|
|
215
|
+
end
|
|
216
|
+
return start + bytes if units == point.column
|
|
217
|
+
|
|
218
|
+
raise RangeError, "column out of bounds"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def anchor(byte_offset, bias: :right)
|
|
222
|
+
validate_offset(byte_offset)
|
|
223
|
+
Anchor.new(byte_offset, bias: bias)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def check_invariants!
|
|
227
|
+
ensure_unchanged!
|
|
228
|
+
raise Error, "invalid piece index" unless @piece_ends.each_cons(2).all? { |left, right| left < right }
|
|
229
|
+
raise Error, "invalid piece size" unless @pieces.all? { |piece| piece.bytesize.positive? }
|
|
230
|
+
@pieces.grep(TextPiece).each { |piece| piece.rope.check_invariants! }
|
|
231
|
+
index_to_eof
|
|
232
|
+
starts = @line_starts.unpack("Q<*")
|
|
233
|
+
raise Error, "invalid line index" unless starts.first == 0 && starts.each_cons(2).all? { |left, right| left < right } && starts.last <= bytesize
|
|
234
|
+
raise Error, "invalid dimension index" unless @checkpoints.each_cons(2).all? { |left, right| left[0] < right[0] }
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def close
|
|
239
|
+
@file.close unless @file.closed?
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def normalize_text(text)
|
|
246
|
+
raise TypeError, "text must be a String" unless text.is_a?(String)
|
|
247
|
+
value = text.encoding == Encoding::UTF_8 ? text.dup : text.encode(Encoding::UTF_8)
|
|
248
|
+
raise ArgumentError, "text must be valid UTF-8" unless value.valid_encoding?
|
|
249
|
+
value
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def byte_bounds(range)
|
|
253
|
+
raise TypeError, "range must be a Range" unless range.is_a?(Range)
|
|
254
|
+
start = range.begin || 0
|
|
255
|
+
finish = range.end || bytesize
|
|
256
|
+
finish += 1 unless range.exclude_end? || range.end.nil?
|
|
257
|
+
raise RangeError, "range out of bounds" unless start.is_a?(Integer) && finish.is_a?(Integer) && start.between?(0, bytesize) && finish.between?(start, bytesize)
|
|
258
|
+
[start, finish]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def slice_bounds(offset, length)
|
|
262
|
+
return byte_bounds(offset) if offset.is_a?(Range)
|
|
263
|
+
raise TypeError, "offset must be an Integer" unless offset.is_a?(Integer)
|
|
264
|
+
length ||= bytesize - offset
|
|
265
|
+
raise RangeError, "slice out of bounds" unless length.is_a?(Integer) && length >= 0 && offset.between?(0, bytesize) && offset + length <= bytesize
|
|
266
|
+
validate_offset(offset)
|
|
267
|
+
validate_offset(offset + length)
|
|
268
|
+
[offset, offset + length]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def validate_offset(offset)
|
|
272
|
+
raise RangeError, "offset out of bounds" unless offset.is_a?(Integer) && offset.between?(0, bytesize)
|
|
273
|
+
return if offset == bytesize || (read_bytes(offset, 1).getbyte(0) & 0xC0) != 0x80
|
|
274
|
+
raise RangeError, "offset inside UTF-8 character"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def pieces_for(start, finish)
|
|
278
|
+
return [] if start == finish
|
|
279
|
+
result = []
|
|
280
|
+
piece_start = 0
|
|
281
|
+
@pieces.each do |piece|
|
|
282
|
+
piece_finish = piece_start + piece.bytesize
|
|
283
|
+
overlap_start = [start, piece_start].max
|
|
284
|
+
overlap_finish = [finish, piece_finish].min
|
|
285
|
+
result << slice_piece(piece, overlap_start - piece_start, overlap_finish - overlap_start) if overlap_start < overlap_finish
|
|
286
|
+
piece_start = piece_finish
|
|
287
|
+
end
|
|
288
|
+
result
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def slice_piece(piece, local_start, length)
|
|
292
|
+
if piece.is_a?(FilePiece)
|
|
293
|
+
FilePiece.new(offset: piece.offset + local_start, bytesize: length).freeze
|
|
294
|
+
else
|
|
295
|
+
TextPiece.new(rope: piece.rope.byteslice(local_start, length)).freeze
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def coalesce(pieces)
|
|
300
|
+
pieces.each_with_object([]) do |piece, result|
|
|
301
|
+
previous = result.last
|
|
302
|
+
if previous.is_a?(FilePiece) && piece.is_a?(FilePiece) && previous.offset + previous.bytesize == piece.offset
|
|
303
|
+
result[-1] = FilePiece.new(offset: previous.offset, bytesize: previous.bytesize + piece.bytesize).freeze
|
|
304
|
+
elsif previous.is_a?(TextPiece) && piece.is_a?(TextPiece) && previous.bytesize + piece.bytesize <= Rope::DEFAULT_CHUNK_SIZE
|
|
305
|
+
result[-1] = TextPiece.new(rope: Rope.new(previous.rope.to_s + piece.rope.to_s)).freeze
|
|
306
|
+
else
|
|
307
|
+
result << piece
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def rebuild_piece_ends
|
|
313
|
+
total = 0
|
|
314
|
+
@piece_ends = @pieces.map { |piece| total += piece.bytesize }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def read_bytes(offset, count)
|
|
318
|
+
ensure_unchanged!
|
|
319
|
+
return "".b if count.zero?
|
|
320
|
+
raise RangeError, "read out of bounds" unless offset >= 0 && count >= 0 && offset + count <= (@piece_ends.last || 0)
|
|
321
|
+
|
|
322
|
+
output = String.new(capacity: count, encoding: Encoding::BINARY)
|
|
323
|
+
piece_index = @piece_ends.bsearch_index { |piece_end| piece_end > offset } || @pieces.length
|
|
324
|
+
piece_start = piece_index.zero? ? 0 : @piece_ends[piece_index - 1]
|
|
325
|
+
while piece_index < @pieces.length
|
|
326
|
+
piece = @pieces[piece_index]
|
|
327
|
+
piece_finish = piece_start + piece.bytesize
|
|
328
|
+
if offset < piece_finish && offset + count > piece_start
|
|
329
|
+
local_start = [offset - piece_start, 0].max
|
|
330
|
+
length = [[offset + count, piece_finish].min - (piece_start + local_start), 0].max
|
|
331
|
+
if piece.is_a?(FilePiece)
|
|
332
|
+
output << read_file(piece.offset + local_start, length)
|
|
333
|
+
else
|
|
334
|
+
append_rope_bytes(output, piece.rope, local_start, length)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
break if output.bytesize == count
|
|
338
|
+
piece_start = piece_finish
|
|
339
|
+
piece_index += 1
|
|
340
|
+
end
|
|
341
|
+
raise Error, "short read" unless output.bytesize == count
|
|
342
|
+
output
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def append_rope_bytes(output, rope, offset, count)
|
|
346
|
+
tree = rope.__send__(:tree)
|
|
347
|
+
index, chunk, prefix = tree.locate(offset, :bytesize)
|
|
348
|
+
local = offset - prefix.bytesize
|
|
349
|
+
while chunk && count.positive?
|
|
350
|
+
length = [count, chunk.text.bytesize - local].min
|
|
351
|
+
output << chunk.text.b.byteslice(local, length)
|
|
352
|
+
count -= length
|
|
353
|
+
local = 0
|
|
354
|
+
index += 1
|
|
355
|
+
chunk = tree[index]
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def read_file(offset, count)
|
|
360
|
+
output = String.new(capacity: count, encoding: Encoding::BINARY)
|
|
361
|
+
while count.positive?
|
|
362
|
+
page, local = offset.divmod(chunk_size)
|
|
363
|
+
data = @cache.delete(page)
|
|
364
|
+
unless data
|
|
365
|
+
data = raw_read(@origin + page * chunk_size, [chunk_size, @source_bytesize - page * chunk_size].min)
|
|
366
|
+
@cached_bytes += data.bytesize
|
|
367
|
+
while @cache.length >= @cache_chunks
|
|
368
|
+
_old_page, old_data = @cache.shift
|
|
369
|
+
@cached_bytes -= old_data.bytesize
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
@cache[page] = data
|
|
373
|
+
length = [count, data.bytesize - local].min
|
|
374
|
+
raise Error, "file was truncated" unless length.positive?
|
|
375
|
+
output << data.byteslice(local, length)
|
|
376
|
+
offset += length
|
|
377
|
+
count -= length
|
|
378
|
+
end
|
|
379
|
+
output
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def raw_read(offset, count)
|
|
383
|
+
return "".b if count.zero?
|
|
384
|
+
if @positioned_reads
|
|
385
|
+
@file.pread(count, offset)
|
|
386
|
+
else
|
|
387
|
+
@read_lock.synchronize do
|
|
388
|
+
@file.seek(offset)
|
|
389
|
+
@file.read(count)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
rescue NotImplementedError
|
|
393
|
+
@positioned_reads = false
|
|
394
|
+
retry
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def reset_index
|
|
398
|
+
@line_starts = [0].pack("Q<")
|
|
399
|
+
@indexed_offset = 0
|
|
400
|
+
@indexed_characters = 0
|
|
401
|
+
@indexed_utf16 = 0
|
|
402
|
+
@pending_cr = nil
|
|
403
|
+
@checkpoints = [[0, 0, 0, nil]]
|
|
404
|
+
@index_complete = false
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def invalidate_index_from(offset)
|
|
408
|
+
# Re-scan the byte before an edit as well: inserting or removing an LF
|
|
409
|
+
# can change whether a preceding CR is one terminator or half of CRLF.
|
|
410
|
+
rewind_to = [offset - 1, 0].max
|
|
411
|
+
checkpoint_index = @checkpoints.rindex { |entry| entry[0] <= rewind_to } || 0
|
|
412
|
+
checkpoint = @checkpoints[checkpoint_index]
|
|
413
|
+
@checkpoints = @checkpoints.take(checkpoint_index + 1)
|
|
414
|
+
@indexed_offset, @indexed_characters, @indexed_utf16, @pending_cr = checkpoint
|
|
415
|
+
starts = @line_starts.unpack("Q<*").take_while { |line_start| line_start <= @indexed_offset }
|
|
416
|
+
@line_starts = starts.pack("Q<*")
|
|
417
|
+
@index_complete = false
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def index_until_line(row)
|
|
421
|
+
scan_index while packed_line_count <= row && !@index_complete
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def index_to(offset)
|
|
425
|
+
scan_index while @indexed_offset < offset && !@index_complete
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def index_to_eof
|
|
429
|
+
scan_index until @index_complete
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def scan_index
|
|
433
|
+
total = bytesize
|
|
434
|
+
if @indexed_offset == total
|
|
435
|
+
append_line_start(@pending_cr) if @pending_cr
|
|
436
|
+
@pending_cr = nil
|
|
437
|
+
@index_complete = true
|
|
438
|
+
@checkpoints[-1] = [@indexed_offset, @indexed_characters, @indexed_utf16, nil]
|
|
439
|
+
return
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
raw = read_bytes(@indexed_offset, [chunk_size, total - @indexed_offset].min)
|
|
443
|
+
complete = complete_utf8_length(raw)
|
|
444
|
+
text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
|
|
445
|
+
raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
|
|
446
|
+
|
|
447
|
+
if text.ascii_only?
|
|
448
|
+
scan_ascii_index(text)
|
|
449
|
+
else
|
|
450
|
+
scan_unicode_index(text)
|
|
451
|
+
end
|
|
452
|
+
@indexed_offset += complete
|
|
453
|
+
@checkpoints << [@indexed_offset, @indexed_characters, @indexed_utf16, @pending_cr]
|
|
454
|
+
scan_index if @indexed_offset == total
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def append_line_start(offset)
|
|
458
|
+
return if unpack_line_start(packed_line_count - 1) == offset
|
|
459
|
+
@line_starts << [offset].pack("Q<")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def packed_line_count = @line_starts.bytesize / 8
|
|
463
|
+
def unpack_line_start(row) = @line_starts.unpack1("Q<", offset: row * 8)
|
|
464
|
+
|
|
465
|
+
def line_index_at(offset)
|
|
466
|
+
low = 0
|
|
467
|
+
high = packed_line_count
|
|
468
|
+
while low < high
|
|
469
|
+
middle = (low + high) / 2
|
|
470
|
+
unpack_line_start(middle) <= offset ? low = middle + 1 : high = middle
|
|
471
|
+
end
|
|
472
|
+
[low - 1, 0].max
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def prefix_dimensions(offset)
|
|
476
|
+
index_to(offset)
|
|
477
|
+
checkpoint_index = @checkpoints.bsearch_index { |entry| entry[0] > offset }
|
|
478
|
+
checkpoint = checkpoint_index ? @checkpoints[checkpoint_index - 1] : @checkpoints.last
|
|
479
|
+
base, characters, units = checkpoint
|
|
480
|
+
text = read_bytes(base, offset - base).force_encoding(Encoding::UTF_8)
|
|
481
|
+
raise RangeError, "offset inside UTF-8 character" unless text.valid_encoding?
|
|
482
|
+
[characters + text.length, units + (text.ascii_only? ? text.bytesize : text.encode(Encoding::UTF_16LE).bytesize / 2)]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def each_codepoint_from(offset)
|
|
486
|
+
while offset < bytesize
|
|
487
|
+
raw = read_bytes(offset, [chunk_size, bytesize - offset].min)
|
|
488
|
+
complete = complete_utf8_length(raw)
|
|
489
|
+
text = raw.byteslice(0, complete).force_encoding(Encoding::UTF_8)
|
|
490
|
+
raise Error, "file contains invalid UTF-8" if complete.zero? || !text.valid_encoding?
|
|
491
|
+
cursor = offset
|
|
492
|
+
text.each_codepoint do |codepoint|
|
|
493
|
+
finish = cursor + codepoint.chr(Encoding::UTF_8).bytesize
|
|
494
|
+
yield codepoint, cursor, finish
|
|
495
|
+
cursor = finish
|
|
496
|
+
end
|
|
497
|
+
offset += complete
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def scan_ascii_index(text)
|
|
502
|
+
base = @indexed_offset
|
|
503
|
+
@indexed_characters += text.bytesize
|
|
504
|
+
@indexed_utf16 += text.bytesize
|
|
505
|
+
scan_from = 0
|
|
506
|
+
if @pending_cr
|
|
507
|
+
if text.start_with?("\n")
|
|
508
|
+
append_line_start(base + 1)
|
|
509
|
+
scan_from = 1
|
|
510
|
+
else
|
|
511
|
+
append_line_start(@pending_cr)
|
|
512
|
+
end
|
|
513
|
+
@pending_cr = nil
|
|
514
|
+
end
|
|
515
|
+
text.b.byteslice(scan_from, text.bytesize - scan_from).scan(/\r\n|[\r\n]/n) do |ending|
|
|
516
|
+
finish = base + scan_from + Regexp.last_match.end(0)
|
|
517
|
+
if ending == "\r" && finish == base + text.bytesize
|
|
518
|
+
@pending_cr = finish
|
|
519
|
+
else
|
|
520
|
+
append_line_start(finish)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def scan_unicode_index(text)
|
|
526
|
+
cursor = @indexed_offset
|
|
527
|
+
text.each_codepoint do |codepoint|
|
|
528
|
+
width = codepoint.chr(Encoding::UTF_8).bytesize
|
|
529
|
+
finish = cursor + width
|
|
530
|
+
@indexed_characters += 1
|
|
531
|
+
@indexed_utf16 += codepoint > 0xFFFF ? 2 : 1
|
|
532
|
+
if @pending_cr
|
|
533
|
+
if codepoint == 10
|
|
534
|
+
append_line_start(finish)
|
|
535
|
+
@pending_cr = nil
|
|
536
|
+
cursor = finish
|
|
537
|
+
next
|
|
538
|
+
end
|
|
539
|
+
append_line_start(@pending_cr)
|
|
540
|
+
@pending_cr = nil
|
|
541
|
+
end
|
|
542
|
+
if codepoint == 13
|
|
543
|
+
@pending_cr = finish
|
|
544
|
+
elsif codepoint == 10 || codepoint == 0x2028 || codepoint == 0x2029
|
|
545
|
+
append_line_start(finish)
|
|
546
|
+
end
|
|
547
|
+
cursor = finish
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def complete_utf8_length(value)
|
|
552
|
+
return 0 if value.empty?
|
|
553
|
+
index = value.bytesize - 1
|
|
554
|
+
index -= 1 while index.positive? && (value.getbyte(index) & 0xC0) == 0x80
|
|
555
|
+
leading = value.getbyte(index)
|
|
556
|
+
expected = leading < 0x80 ? 1 : leading < 0xE0 ? 2 : leading < 0xF0 ? 3 : 4
|
|
557
|
+
value.bytesize - index < expected ? index : value.bytesize
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def file_stamp(stat)
|
|
561
|
+
[stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def ensure_unchanged!
|
|
565
|
+
raise IOError, "closed file" if @file.closed?
|
|
566
|
+
unchanged = file_stamp(@file.stat) == @stamp && file_stamp(File.stat(@path)) == @path_stamp
|
|
567
|
+
raise Error, "file changed on disk; reopen it" unless unchanged
|
|
568
|
+
rescue Errno::ENOENT
|
|
569
|
+
raise Error, "file changed on disk; reopen it"
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
data/lib/denebola/version.rb
CHANGED
data/lib/denebola.rb
CHANGED
data/sig/denebola.rbs
CHANGED
|
@@ -146,6 +146,43 @@ module Denebola
|
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
class LazyRope
|
|
150
|
+
DEFAULT_CHUNK_SIZE: Integer
|
|
151
|
+
DEFAULT_CACHE_CHUNKS: Integer
|
|
152
|
+
attr_reader chunk_size: Integer
|
|
153
|
+
attr_reader cached_bytes: Integer
|
|
154
|
+
def self.open: (String | _ToPath path, ?chunk_size: Integer, ?encoding: Encoding, ?cache_chunks: Integer) -> LazyRope
|
|
155
|
+
def initialize: (String | _ToPath path, ?chunk_size: Integer, ?encoding: Encoding, ?cache_chunks: Integer) -> void
|
|
156
|
+
def lazy?: () -> true
|
|
157
|
+
def closed?: () -> bool
|
|
158
|
+
def bytesize: () -> Integer
|
|
159
|
+
def size: () -> Integer
|
|
160
|
+
def empty?: () -> bool
|
|
161
|
+
def length: () -> Integer
|
|
162
|
+
def utf16_length: () -> Integer
|
|
163
|
+
def line_count: (?exact: bool) -> Integer
|
|
164
|
+
def line_start: (Integer) -> Integer
|
|
165
|
+
def line: (Integer) -> String
|
|
166
|
+
def byteslice: (Integer | Range[Integer?], ?Integer?) -> Rope
|
|
167
|
+
def each_chunk: () { (String) -> void } -> self
|
|
168
|
+
| () -> Enumerator[String, self]
|
|
169
|
+
def materialize: (?Range[Integer?]?) -> Rope
|
|
170
|
+
def to_s: () -> String
|
|
171
|
+
def edit: (Range[Integer?], String) -> self
|
|
172
|
+
def insert: (Integer, String) -> self
|
|
173
|
+
def delete: (Range[Integer?]) -> self
|
|
174
|
+
def replace: (Range[Integer?], String) -> self
|
|
175
|
+
def point_at: (Integer) -> Point
|
|
176
|
+
def offset_at: (Point) -> Integer
|
|
177
|
+
def utf16_offset_at: (Integer) -> Integer
|
|
178
|
+
def offset_at_utf16: (Integer) -> Integer
|
|
179
|
+
def utf16_point_at: (Integer) -> Point
|
|
180
|
+
def offset_at_utf16_point: (Point) -> Integer
|
|
181
|
+
def anchor: (Integer, ?bias: :left | :right) -> Anchor
|
|
182
|
+
def check_invariants!: () -> true
|
|
183
|
+
def close: () -> nil
|
|
184
|
+
end
|
|
185
|
+
|
|
149
186
|
class Anchor
|
|
150
187
|
attr_reader offset: Integer
|
|
151
188
|
attr_reader bias: :left | :right
|
metadata
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: denebola
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
11
12
|
dependencies: []
|
|
12
|
-
description: Immutable
|
|
13
|
-
|
|
13
|
+
description: Immutable text ropes plus bounded-memory, file-backed editing with line
|
|
14
|
+
and UTF-16 indexing.
|
|
14
15
|
email:
|
|
15
16
|
- t.yudai92@gmail.com
|
|
16
17
|
executables: []
|
|
@@ -25,6 +26,7 @@ files:
|
|
|
25
26
|
- lib/denebola/cursor.rb
|
|
26
27
|
- lib/denebola/dimensions.rb
|
|
27
28
|
- lib/denebola/error.rb
|
|
29
|
+
- lib/denebola/lazy_rope.rb
|
|
28
30
|
- lib/denebola/point.rb
|
|
29
31
|
- lib/denebola/rope.rb
|
|
30
32
|
- lib/denebola/text_summary.rb
|
|
@@ -38,6 +40,7 @@ metadata:
|
|
|
38
40
|
allowed_push_host: https://rubygems.org
|
|
39
41
|
source_code_uri: https://github.com/noxdea/denebola
|
|
40
42
|
rubygems_mfa_required: 'true'
|
|
43
|
+
post_install_message:
|
|
41
44
|
rdoc_options: []
|
|
42
45
|
require_paths:
|
|
43
46
|
- lib
|
|
@@ -52,7 +55,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
52
55
|
- !ruby/object:Gem::Version
|
|
53
56
|
version: '0'
|
|
54
57
|
requirements: []
|
|
55
|
-
rubygems_version: 4.
|
|
58
|
+
rubygems_version: 3.4.19
|
|
59
|
+
signing_key:
|
|
56
60
|
specification_version: 4
|
|
57
61
|
summary: Persistent summary B+ trees and Unicode text ropes in pure Ruby
|
|
58
62
|
test_files: []
|