Class: CoPlan::Plans::TransformRange

Inherits:
Object
  • Object
show all
Defined in:
app/services/coplan/plans/transform_range.rb

Overview

Operational Transform engine for character-range transformations.

INVARIANT: Every PlanVersion’s operations_json entries MUST contain positional metadata (resolved_range + new_range/delta, or replacements). All content edits flow through Plans::ApplyOperations which stores this data. There are no legacy or non-positional edit paths.

If an operation lacks positional metadata, transform_through_versions raises Conflict rather than silently skipping it.

Defined Under Namespace

Classes: Conflict

Class Method Summary collapse

Class Method Details

.transform(range, edit_data) ⇒ Object

Transform a range [s, e] through an edit that replaced [s2, e2] with text of length new_length. Returns the transformed [s, e] or raises Conflict if ranges overlap.

edit_data is a hash with:

resolved_range: [s2, e2] 

Raises:



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'app/services/coplan/plans/transform_range.rb', line 22

def self.transform(range, edit_data)
  s, e = range
  edit_data = edit_data.transform_keys(&:to_s)

  s2, e2 = edit_data["resolved_range"]

  # Calculate delta from the edit
  if edit_data.key?("new_range")
    new_s2, new_e2 = edit_data["new_range"]
    delta = (new_e2 - new_s2) - (e2 - s2)
  elsif edit_data.key?("delta")
    delta = edit_data["delta"].to_i
  else
    raise ArgumentError, "edit_data must contain 'new_range' or 'delta'"
  end

  # Zero-width insert point: special handling
  if s == e
    # Insert point: shift if edit is strictly before
    if e2 <= s
      return [s + delta, e + delta]
    elsif s2 > s
      return [s, e]
    else
      raise Conflict, "Edit overlaps with insert point"
    end
  end

  # Case 1: Edit is entirely before our range (e2 <= s)
  if e2 <= s
    return [s + delta, e + delta]
  end

  # Case 2: Edit is entirely after our range (s2 >= e)
  if s2 >= e
    return [s, e]
  end

  # Case 3: Overlap — conflict
  raise Conflict, "Ranges overlap: [#{s}, #{e}] conflicts with edit at [#{s2}, #{e2}]"
end

.transform_through_versions(range, versions) ⇒ Object

Transform a range through a sequence of edits from a PlanVersion. Each version has operations_json with resolved position data.

versions: array of PlanVersion records (or hashes with operations_json),

ordered by revision ascending

Returns the transformed range or raises Conflict.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/services/coplan/plans/transform_range.rb', line 70

def self.transform_through_versions(range, versions)
  current_range = range.dup

  versions.each do |version|
    ops = version.is_a?(Hash) ? version[:operations_json] || version["operations_json"] : version.operations_json
    next if ops.blank?

    ops.each do |op_data|
      op_data = op_data.transform_keys(&:to_s)

      if op_data.key?("replacements")
        # replace_all: multiple ranges, each shifts independently
        # Process in reverse order (highest position first) since each was
        # already adjusted for previous replacements during application
        op_data["replacements"].sort_by { |r| -r["resolved_range"][0] }.each do |replacement|
          current_range = transform(current_range, replacement)
        end
      elsif op_data.key?("resolved_range")
        current_range = transform(current_range, op_data)
      else
        raise Conflict, "Operation lacks positional metadata (resolved_range or replacements)"
      end
    end
  end

  current_range
end