Class: Canopus::Buffer

Inherits:
Object
  • Object
show all
Defined in:
lib/canopus/buffer.rb,
sig/canopus.rbs,
sig/search_services.rbs

Direct Known Subclasses

MultiBuffer

Defined Under Namespace

Classes: Anchor, Transaction

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(text = "", path: nil, encoding: nil, bom: "".b, disk_digest: nil, read_only: false, draft: false, rope: nil, detection: nil) ⇒ Buffer

Returns a new instance of Buffer.

Parameters:

  • text (String) (defaults to: "")
  • path: (String, nil) (defaults to: nil)
  • encoding: (Encoding) (defaults to: nil)
  • bom: (String) (defaults to: "".b)
  • disk_digest: (String, nil) (defaults to: nil)
  • read_only: (Boolean) (defaults to: false)
  • draft: (Boolean) (defaults to: false)
  • rope: (rope, nil) (defaults to: nil)
  • detection: (Object) (defaults to: nil)


97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/canopus/buffer.rb', line 97

def initialize(text = "", path: nil, encoding: nil, bom: "".b, disk_digest: nil, read_only: false,
  draft: false, rope: nil, detection: nil)
  @rope = rope || Denebola::Rope.new(text)
  @save_mutex = Mutex.new
  @path = path && File.expand_path(path)
  requested = encoding && Menkar.detect("".b, hint: encoding).encoding
  @detection = detection || self.class.send(:detection_for_text, text, encoding: requested, bom: bom)
  @encoding, @bom, @disk_digest = @detection.encoding || requested || Encoding::UTF_8,
    @detection.bom.empty? ? bom : @detection.bom, disk_digest
  @saved_rope, @version, @history, @redo = @rope, 0, [], []
  @saved_rope = nil if draft
  @listeners, @anchors, @next_anchor, @selections = [], {}, 0, []
  @notification_errors = [].freeze
  @newline = @detection.newline
  @line_ending = text[/\r\n|\n|\r|\u2028|\u2029/] || "\n"
  @line_ending = if rope.respond_to?(:line_ending)
    rope.line_ending
  elsif rope && text.empty?
    snapshot_line_ending(rope)
  else
    @line_ending
  end
  @read_only = read_only
rescue Menkar::Error => error
  raise Error, error.message
end

Instance Attribute Details

#bom ⇒ String (readonly)

Returns the value of attribute bom.

Returns:

  • (String)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def bom
  @bom
end

#detection ⇒ Object (readonly)

Returns the value of attribute detection.

Returns:

  • (Object)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def detection
  @detection
end

#disk_digest ⇒ String? (readonly)

Returns the value of attribute disk_digest.

Returns:

  • (String, nil)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def disk_digest
  @disk_digest
end

#encoding ⇒ Encoding (readonly)

Returns the value of attribute encoding.

Returns:

  • (Encoding)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def encoding
  @encoding
end

#history ⇒ Array[Transaction] (readonly)

Returns the value of attribute history.

Returns:



14
15
16
# File 'lib/canopus/buffer.rb', line 14

def history
  @history
end

#line_ending ⇒ String (readonly)

Returns the value of attribute line_ending.

Returns:

  • (String)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def line_ending
  @line_ending
end

#newline ⇒ Symbol? (readonly)

Returns the value of attribute newline.

Returns:

  • (Symbol, nil)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def newline
  @newline
end

#notification_errors ⇒ Array[Error] (readonly)

Returns the value of attribute notification_errors.

Returns:



14
15
16
# File 'lib/canopus/buffer.rb', line 14

def notification_errors
  @notification_errors
end

#path ⇒ String? (readonly)

Returns the value of attribute path.

Returns:

  • (String, nil)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def path
  @path
end

#read_only ⇒ Boolean (readonly)

Returns the value of attribute read_only.

Returns:

  • (Boolean)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def read_only
  @read_only
end

#rope ⇒ rope (readonly)

Returns the value of attribute rope.

Returns:



14
15
16
# File 'lib/canopus/buffer.rb', line 14

def rope
  @rope
end

#selections ⇒ Array[Selection]

Returns the value of attribute selections.

Returns:



16
17
18
# File 'lib/canopus/buffer.rb', line 16

def selections
  @selections
end

#version ⇒ Integer (readonly)

Returns the value of attribute version.

Returns:

  • (Integer)


14
15
16
# File 'lib/canopus/buffer.rb', line 14

def version
  @version
end

Class Method Details

.decode_bytes(raw) ⇒ Array(String, Encoding, String)

Decode worktree files and historical Git blobs by the same rules.

Parameters:

  • raw (String)

Returns:

  • (Array(String, Encoding, String)) —

    UTF-8 text, source encoding, BOM



66
67
68
69
70
71
72
73
74
# File 'lib/canopus/buffer.rb', line 66

def self.decode_bytes(raw)
  raw = raw.b unless raw.encoding == Encoding::BINARY
  detection = detect_bytes(raw)
  nul_binary = raw.include?("\0") && detection.bom.empty? && raw.bytesize < 8
  raise Error, "binary file contains NUL bytes" if detection.binary || (detection.encoding.nil? && raw.include?("\0")) || nul_binary
  [Menkar.decode(raw, detection), detection.encoding, detection.bom]
rescue Menkar::Error => error
  raise Error, error.message
end

.open(path, large_file_threshold: 100 << 20, encoding: nil) ⇒ instance

Parameters:

  • path (String)
  • large_file_threshold: (Integer) (defaults to: 100 << 20)
  • encoding: (Encoding, nil) (defaults to: nil)

Returns:

  • (instance)


18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/canopus/buffer.rb', line 18

def self.open(path, large_file_threshold: 100 << 20, encoding: nil)
  if File.size(path) >= large_file_threshold
    detection = detect_file(path, encoding: encoding)
    raise Error, "large-file mode requires UTF-8 text" unless detection.encoding == Encoding::UTF_8
    require_relative "lazy_rope"
    rope = Denebola::LazyRope.open(path, chunk_size: 65_536, cache_chunks: 32)
    rope.line_count(exact: true)
    return new("", path: path, read_only: true, rope: rope, encoding: detection.encoding,
      bom: detection.bom, detection: detection)
  end
  snapshot = Xamidimura::SourceRevision.read(path)
  raw = snapshot.bytes
  detection = detect_bytes(raw, encoding: encoding)
  raise Error, "cannot edit binary file" if detection.binary
  text = Menkar.decode(raw, detection)
  new(text, path: path, encoding: detection.encoding, bom: detection.bom, detection: detection,
    disk_digest: snapshot.digest)
rescue Menkar::Error => error
  raise Error, error.message
rescue Xamidimura::Error => error
  raise Error, "cannot read #{path}: #{error.message}"
rescue EncodingError => error
  raise Error, "cannot decode #{path}: #{error.message}"
end

Instance Method Details

#anchor(offset, bias: :right) ⇒ Anchor

Parameters:

  • offset (Integer)
  • bias: (bias) (defaults to: :right)

Returns:

Raises:

  • (ArgumentError)


167
168
169
170
171
172
173
174
# File 'lib/canopus/buffer.rb', line 167

def anchor(offset, bias: :right)
  raise ArgumentError, "bias must be :left or :right" unless bias == :left || bias == :right
  @rope.point_at(offset)
  anchor = Anchor.new(@next_anchor, bias)
  @next_anchor += 1
  @anchors[anchor] = offset
  anchor
end

#begin_undo_group ⇒ self

Returns:

  • (self)


190
191
192
193
194
195
# File 'lib/canopus/buffer.rb', line 190

def begin_undo_group
  @undo_group_depth ||= 0
  @undo_group_start = @history.length if @undo_group_depth.zero?
  @undo_group_depth += 1
  self
end

#close ⇒ void

This method returns an undefined value.



125
# File 'lib/canopus/buffer.rb', line 125

def close = (@rope.close if @rope.respond_to?(:close))

#convert_line_endings(to: :lf) ⇒ Boolean Also known as: convert_newlines

Parameters:

  • to: (:lf, :crlf, :cr) (defaults to: :lf)

Returns:

  • (Boolean)


326
327
328
329
330
331
332
333
334
335
336
# File 'lib/canopus/buffer.rb', line 326

def convert_line_endings(to: :lf)
  normalized, = Menkar.normalize_newlines(text, to: to)
  return false if normalized == text

  edit([[0...rope.bytesize, normalized]], kind: :format)
  @line_ending, @newline = {lf: "\n", crlf: "\r\n", cr: "\r"}.fetch(to), to
  @detection = @detection.with(newline: to) if @detection.respond_to?(:with)
  true
rescue Menkar::Error => error
  raise Error, error.message
end

#copy_anchor(source, anchor) ⇒ Anchor

Copy a registered position only from the exact same immutable snapshot. This avoids repeating a rope lookup for privately prepared excerpts.

Parameters:

Returns:

Raises:

  • (ArgumentError)


180
181
182
183
184
185
186
187
188
# File 'lib/canopus/buffer.rb', line 180

def copy_anchor(source, anchor)
  raise ArgumentError, "anchor source must be a regular buffer" unless source.is_a?(Buffer) && !source.is_a?(MultiBuffer)
  raise ArgumentError, "anchor source snapshot differs" unless source.rope.equal?(@rope)
  offset = source.resolve(anchor)
  copied = Anchor.new(@next_anchor, anchor.bias)
  @next_anchor += 1
  @anchors[copied] = offset
  copied
end

#dirty? ⇒ Boolean

Returns:

  • (Boolean)


128
# File 'lib/canopus/buffer.rb', line 128

def dirty? = !@rope.equal?(@saved_rope)

#edit(changes, kind: :edit, selections: nil, before_selections: @selections, time: Process.clock_gettime(Process::CLOCK_MONOTONIC), group: false) ⇒ patch?

Parameters:

  • changes (changes)
  • kind: (Symbol) (defaults to: :edit)
  • selections: (Array[Selection], nil) (defaults to: nil)
  • before_selections: (Array[Selection]) (defaults to: @selections)
  • time: (Numeric) (defaults to: Process.clock_gettime(Process::CLOCK_MONOTONIC))
  • group: (Boolean) (defaults to: false)

Returns:

  • (patch, nil)

Raises:



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/canopus/buffer.rb', line 203

def edit(changes, kind: :edit, selections: nil, before_selections: @selections, time: Process.clock_gettime(Process::CLOCK_MONOTONIC), group: false)
  raise Error, "buffer is read-only" if @read_only
  return if changes.empty?
  before, after = @rope, @rope.apply_edits(changes)
  patch = Patch.new(before, after, changes)
  # Direct edits (formatting, LSP, excerpt sources) have no Editor to supply
  # the post-edit cursor positions. Preserve explicit selection overrides.
  selections ||= before_selections.map do |selection|
    selection.with(anchor: patch.map_offset(selection.anchor), head: patch.map_offset(selection.head))
  end
  transaction = Transaction.new(before, after, before_selections.dup.freeze, selections.dup.freeze, patch, kind, time)
  previous = @history.last
  explicit_group = @undo_group_depth&.positive?
  merge = explicit_group ? @history.length > @undo_group_start : group && previous && previous.kind == kind && time - previous.time < 0.3 && previous.after_selections == before_selections
  if merge && previous && previous.after.equal?(before)
    previous.after, previous.after_selections, previous.time = after, transaction.after_selections, time
    previous.patch = previous.patch.compose(patch)
  else
    @history << transaction
  end
  @redo.clear
  @selections = selections
  apply_snapshot(after, patch)
  patch
end

#end_undo_group ⇒ self

Returns:

  • (self)

Raises:



196
197
198
199
200
201
# File 'lib/canopus/buffer.rb', line 196

def end_undo_group
  raise Error, "no undo group is open" unless @undo_group_depth&.positive?
  @undo_group_depth -= 1
  @undo_group_start = nil if @undo_group_depth.zero?
  self
end

#ensure_final_newline ⇒ Boolean

Returns:

  • (Boolean)


351
352
353
354
355
# File 'lib/canopus/buffer.rb', line 351

def ensure_final_newline
  return false if rope.empty? || text.end_with?("\n", "\r", "\u2028", "\u2029")
  edit([[rope.bytesize...rope.bytesize, @line_ending]], kind: :format)
  true
end

#line(row) ⇒ String

Parameters:

  • row (Integer)

Returns:

  • (String)


126
# File 'lib/canopus/buffer.rb', line 126

def line(row) = rope.line(row)

#line_count ⇒ Integer

Returns:

  • (Integer)


127
# File 'lib/canopus/buffer.rb', line 127

def line_count = rope.line_count

#mixed_line_endings? ⇒ Boolean

Returns:

  • (Boolean)


324
# File 'lib/canopus/buffer.rb', line 324

def mixed_line_endings? = @newline == :mixed

#on_edit {|arg0| ... } ⇒ Zaniah::Subscription

Notifications observe an already committed edit (including undo/redo). A listener's StandardError is reported, not raised as an edit failure; remaining listeners still run. Keep only the latest 32 bounded messages.

Yields:

Yield Parameters:

  • arg0 (patch)

Yield Returns:

  • (void)

Returns:

  • (Zaniah::Subscription)


163
164
165
166
# File 'lib/canopus/buffer.rb', line 163

def on_edit(&listener)
  @listeners << listener
  Zaniah::Subscription.new { @listeners.delete(listener) }
end

#persistent_undo_record(max_entries:) ⇒ Hash[String, untyped]?

Persistent undo stores edits, not rope snapshots. Rebuilding from the current file backwards keeps records bounded while retaining Composite's intermediate coordinate spaces.

Parameters:

  • max_entries: (Integer)

Returns:

  • (Hash[String, untyped], nil)


250
251
252
253
254
# File 'lib/canopus/buffer.rb', line 250

def persistent_undo_record(max_entries:)
  entries = @history.last(max_entries)
  return if entries.empty?
  {"history" => entries.map { |transaction| persistent_undo_transaction(transaction) }}
end

#redo ⇒ Boolean

Returns:

  • (Boolean)

Raises:



237
238
239
240
241
242
243
244
245
# File 'lib/canopus/buffer.rb', line 237

def redo
  raise Error, "end the undo group before redo" if @undo_group_depth&.positive?
  transaction = @redo.pop
  return false unless transaction
  @history << transaction
  @selections = transaction.after_selections.dup
  apply_snapshot(transaction.after, transaction.patch)
  true
end

#release_anchor(anchor) ⇒ Integer?

Parameters:

Returns:

  • (Integer, nil)


176
# File 'lib/canopus/buffer.rb', line 176

def release_anchor(anchor) = @anchors.delete(anchor)

#reload(force: false, encoding: nil) ⇒ self

Parameters:

  • force: (Boolean) (defaults to: false)
  • encoding: (Encoding, nil) (defaults to: nil)

Returns:

  • (self)


129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/canopus/buffer.rb', line 129

def reload(force: false, encoding: nil)
  raise SaveConflict, "buffer has unsaved changes" if dirty? && !force
  raise Error, "buffer has no file path" unless @path
  if @rope.respond_to?(:lazy?) && @rope.lazy?
    fresh = Buffer.open(@path, large_file_threshold: 0, encoding: encoding)
    old = @rope
    apply_snapshot(fresh.rope, Patch::Reload.new(old, fresh.rope))
    old.close
  else
    fresh = Buffer.open(@path, encoding: encoding)
    raise Error, "file grew beyond the editable limit; reopen it read-only" if fresh.read_only
    before, after = text, fresh.text
    if before != after
      first, shared = 0, [before.bytesize, after.bytesize].min
      first += 4096 while first + 4096 <= shared && before.byteslice(first, 4096) == after.byteslice(first, 4096)
      first += 1 while first < shared && before.getbyte(first) == after.getbyte(first)
      first -= 1 while first.positive? && before.getbyte(first)&.&(0xc0) == 0x80
      last = 0
      last += 4096 while last + 4096 <= shared - first && before.byteslice(before.bytesize - last - 4096, 4096) == after.byteslice(after.bytesize - last - 4096, 4096)
      last += 1 while last < shared - first && before.getbyte(before.bytesize - last - 1) == after.getbyte(after.bytesize - last - 1)
      last -= 1 while last.positive? && before.getbyte(before.bytesize - last)&.&(0xc0) == 0x80
      edit([[first...(before.bytesize - last), after.byteslice(first, after.bytesize - first - last)]], kind: :reload)
    end
  end
  @saved_rope = @rope
  @disk_digest, @encoding, @bom, @line_ending, @newline, @detection = fresh.disk_digest, fresh.encoding, fresh.bom,
    fresh.line_ending, fresh.newline, fresh.detection
  self
ensure
  fresh&.close unless fresh&.rope.equal?(@rope)
end

#relocate(path) ⇒ String

Parameters:

  • path (String)

Returns:

  • (String)


124
# File 'lib/canopus/buffer.rb', line 124

def relocate(path) = @path = File.expand_path(path)

#resolve(anchor) ⇒ Integer

Parameters:

Returns:

  • (Integer)


175
# File 'lib/canopus/buffer.rb', line 175

def resolve(anchor) = @anchors.fetch(anchor)

#restore_persistent_undo!(record) ⇒ true

Parameters:

  • record (Hash[String, untyped])

Returns:

  • (true)

Raises:



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/canopus/buffer.rb', line 256

def restore_persistent_undo!(record)
  entries = record.fetch("history")
  raise Error, "invalid persistent undo history" unless entries.is_a?(Array) && !entries.empty?
  current = @rope
  restored = entries.reverse_each.map do |entry|
    raise Error, "invalid persistent undo transaction" unless entry.is_a?(Hash)
    patch, before = persistent_undo_patch_before(entry.fetch("patch"), current)
    before_selections = persistent_undo_selections(entry.fetch("before_selections"), before.bytesize)
    after_selections = persistent_undo_selections(entry.fetch("after_selections"), current.bytesize)
    kind = entry.fetch("kind")
    raise Error, "invalid persistent undo kind" unless kind.is_a?(String) && kind.bytesize.between?(1, 128) && kind.match?(/\A[a-zA-Z0-9_:-]+\z/)
    current = before
    Transaction.new(before, patch.after, before_selections, after_selections, patch, kind.to_sym, 0.0)
  end.reverse
  @history.replace(restored)
  @redo.clear
  true
end

#roundtrip?(encoding: nil) ⇒ Boolean

Parameters:

  • encoding: (Encoding, nil) (defaults to: nil)

Returns:

  • (Boolean)


315
316
317
318
319
320
321
322
# File 'lib/canopus/buffer.rb', line 315

def roundtrip?(encoding: nil)
  return true unless @detection && !@detection.binary

  detection = encoding ? detection_for_encoding(encoding) : @detection
  Menkar.roundtrip?(text, detection)
rescue Menkar::Error => error
  raise Error, error.message
end

#save(path = @path, force: false, encoding: nil) ⇒ self

Encode first, write and fsync a sibling temporary file, atomically rename.

Parameters:

  • path (String, nil) (defaults to: @path)
  • force: (Boolean) (defaults to: false)
  • encoding: (Encoding, nil) (defaults to: nil)

Returns:

  • (self)


276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/canopus/buffer.rb', line 276

def save(path = @path, force: false, encoding: nil)
  @save_mutex.synchronize do
    raise Error, "no file path" unless path
    raise Error, "buffer is read-only" if @read_only
    destination = File.expand_path(path)
    destination = File.realpath(destination) if File.symlink?(destination)
    original = File.file?(destination) ? File.binread(destination) : nil
    same_file = @path && (File.expand_path(path) == @path || (File.exist?(@path) && File.exist?(destination) && File.identical?(@path, destination)))
    if !force && same_file && @disk_digest && (!original || Xamidimura::SourceRevision.digest(original) != @disk_digest)
      raise SaveConflict, "file changed on disk: #{path}"
    end
    raise SaveConflict, "destination exists: #{path}" if !force && original && (!same_file || !@disk_digest)
    snapshot = @rope
    detection = encoding ? detection_for_encoding(encoding) : @detection
    encoded = Menkar.encode(snapshot.to_s, detection)
    Tempfile.create([".canopus-", ".tmp"], File.dirname(destination), binmode: true) do |file|
      file.chmod(File.stat(destination).mode & 0o777) if original
      file.write(encoded)
      file.flush
      file.fsync
      file.close
      current = File.file?(destination) ? File.binread(destination) : nil
      raise SaveConflict, "file changed during save: #{path}" if !force && current != original
      File.rename(file.path, destination)
    end
    @path = File.expand_path(path)
    if encoding
      @detection = detection
      @encoding, @bom = detection.encoding, detection.bom
    end
    @saved_rope, @disk_digest = snapshot, Xamidimura::SourceRevision.digest(encoded)
    self
  end
rescue EncodingError => error
  raise Error, "cannot save using #{@encoding}: #{error.message}"
rescue Menkar::Error => error
  raise Error, error.message
end

#text ⇒ String

Returns:

  • (String)


123
# File 'lib/canopus/buffer.rb', line 123

def text = rope.to_s

#trim_trailing_whitespace ⇒ patch?

Returns:

  • (patch, nil)


339
340
341
342
343
344
345
346
347
348
349
# File 'lib/canopus/buffer.rb', line 339

def trim_trailing_whitespace
  changes = []
  rope.line_count.times do |row|
    value = rope.line(row)
    whitespace = value[/[ \t]+\z/]
    next unless whitespace
    ending = rope.line_start(row) + value.bytesize
    changes << [(ending - whitespace.bytesize)...ending, ""]
  end
  edit(changes, kind: :format)
end

#undo ⇒ Boolean

Returns:

  • (Boolean)

Raises:



228
229
230
231
232
233
234
235
236
# File 'lib/canopus/buffer.rb', line 228

def undo
  raise Error, "end the undo group before undo" if @undo_group_depth&.positive?
  transaction = @history.pop
  return false unless transaction
  @redo << transaction
  @selections = transaction.before_selections.dup
  apply_snapshot(transaction.before, transaction.patch.inverse)
  true
end