porrima 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a343ef7bf35f119ee70c360d6fb3f4d69e20e052911cbd8f449ea87964b40284
4
- data.tar.gz: fabc56760d330596512fa8481359dcc364a7793af4e8984e98a920b230c6b052
3
+ metadata.gz: 12e94d3fff8b491f17f5cb13beb41d396496816c54995c16be69b3aebdba875f
4
+ data.tar.gz: df24fab43b8358f2fcc522b548a1ead14ef53faf365a6601d0bc10c3a798aeed
5
5
  SHA512:
6
- metadata.gz: 48f5568c01680dab34f4633161d19d3e34486e2b8e7299ddf31eb40765fabe57f7ea796c73b11a39686bc3237a4c52d70c83422f842c98f94aefe90b681c66d6
7
- data.tar.gz: 46430b633bdc526768d2f186d2f73d6030a7d764a97c65692033246204441ead795faacb391bdd24652de5231cd659d63a617aa03b65b5a8e38fb4a142733637
6
+ metadata.gz: 24a68e804a09990a553b353e9ca2c547f64ca72cb382a121212ee1222f44b75d17b0ed07ca499a4200539279d245484723e602b43a3bb1972c51cbd86251b81c
7
+ data.tar.gz: b61f20f0ea9d25c231e30b1efd01a91152f7e3e76bd69edd3dec00c634c27123fcb15c9d2e4c66a33f35a2207d0b0c72d800cc2c605578ca42b15823a975824c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-09-15
4
+
5
+ - Add zero-based merge output regions for conflict marker blocks.
6
+ - Add immutable single and bulk conflict resolution with custom replacement text.
7
+ - Add word-level inline refinement for conflict alternatives.
8
+
3
9
  ## 0.1.0 — 2026-09-12
4
10
 
5
11
  - Initial release.
data/README.md CHANGED
@@ -1,18 +1,39 @@
1
- # Porrima
2
-
3
- [![Gem version](https://img.shields.io/gem/v/porrima.svg)](https://rubygems.org/gems/porrima)
4
- [![CI](https://github.com/noxdea/porrima/actions/workflows/main.yml/badge.svg)](https://github.com/noxdea/porrima/actions/workflows/main.yml)
5
- [![CRuby 3.1+](https://img.shields.io/badge/CRuby-%3E%3D%203.1-cc342d.svg)](porrima.gemspec)
6
- [![MIT license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
7
-
8
- Line, word, and three-way diff, patch, and merge in pure Ruby.
9
-
10
- ## Scope
11
-
12
- Porrima computes from two or three texts. Its core never reads files, knows
13
- nothing about Git, and has no runtime dependencies. Color, width, truncation,
14
- caching, and other display policy belong to the caller. The CLI only connects
15
- files to the library's interoperable formats.
1
+ <h1 align="center">Porrima</h1>
2
+
3
+ <p align="center">
4
+ <strong>Dependency-free line, word, and three-way diff, exact patching, and structured merge for Ruby.</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/porrima"><img src="https://img.shields.io/gem/v/porrima.svg" alt="Gem version"></a>
9
+ <a href="https://github.com/noxdea/porrima/actions/workflows/main.yml"><img src="https://github.com/noxdea/porrima/actions/workflows/main.yml/badge.svg" alt="CI status"></a>
10
+ <a href="porrima.gemspec"><img src="https://img.shields.io/badge/CRuby-%3E%3D%203.1-cc342d.svg" alt="CRuby 3.1 or newer"></a>
11
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://noxdea.github.io/porrima/">Website</a> ·
16
+ <a href="#features">Features</a> ·
17
+ <a href="#installation">Installation</a> ·
18
+ <a href="#quick-start">Quick start</a> ·
19
+ <a href="#core-api">Core API</a> ·
20
+ <a href="#cli">CLI</a>
21
+ </p>
22
+
23
+ ---
24
+
25
+ Porrima turns two or three texts into structured edits, hunks, patches, and
26
+ merge conflicts. Its core performs no I/O, knows nothing about Git, and leaves
27
+ color, width, truncation, caching, and other display policy to the caller.
28
+
29
+ ## Features
30
+
31
+ - Linear-space Myers diff for lines and inline word or character refinement
32
+ - Context hunks, change statistics, gutter marks, and paired rows
33
+ - Unified diff output with strict patch parsing, application, and reversal
34
+ - Three-way merge with structured conflicts, output regions, and immutable resolution
35
+ - CLI output as unified text or JSON
36
+ - No runtime dependencies
16
37
 
17
38
  ## Installation
18
39
 
@@ -20,43 +41,96 @@ files to the library's interoperable formats.
20
41
  gem install porrima
21
42
  ```
22
43
 
44
+ Or add `gem "porrima"` to your Gemfile. Porrima requires CRuby 3.1 or newer.
45
+
23
46
  ## Quick start
24
47
 
25
48
  ```ruby
26
49
  require "porrima"
27
50
 
28
- result = Porrima.diff("one\ntwo\n", "one\nchanged\n", context: 1)
29
- result.hunks
30
- result.stat.to_h # => {insertions: 1, deletions: 1, hunks: 1}
31
- result.marks
32
- result.rows
33
- result.to_unified(old_name: "a/example", new_name: "b/example")
51
+ before = "one\ntwo\n"
52
+ after = "one\nchanged\n"
53
+ diff = Porrima.diff(before, after, context: 1)
54
+
55
+ diff.stat.to_h
56
+ # => { insertions: 1, deletions: 1, hunks: 1 }
57
+
58
+ puts diff.to_unified(old_name: "a/example", new_name: "b/example")
59
+ ```
60
+
61
+ ## Core API
62
+
63
+ | API | Result |
64
+ | --- | --- |
65
+ | `Porrima.diff(before, after)` | Lazy `Diff` snapshot with every result view |
66
+ | `Porrima.edits(before, after)` | Ordered equal, deleted, and inserted lines |
67
+ | `Porrima.hunks(before, after)` | Context-aware change groups |
68
+ | `Porrima.unified(before, after)` | Unified diff text |
69
+ | `Porrima.apply(text, hunk)` | Text with one exact hunk applied |
70
+ | `Porrima.revert(text, hunk)` | Text with one exact hunk reversed |
71
+ | `Porrima::Inline.refine(before, after)` | Word- or character-level spans |
72
+ | `Porrima::Patch.parse(text)` | Parsed unified file diffs |
73
+ | `Porrima::Merge.three_way(...)` | Structured three-way merge result |
74
+
75
+ ### Diff result
76
+
77
+ | View | Contains |
78
+ | --- | --- |
79
+ | `edits` | Equal, deleted, or inserted lines with old and new positions |
80
+ | `hunks` | Context-aware groups with exact old and new text |
81
+ | `stat` | Insertion, deletion, and hunk counts |
82
+ | `marks` | Added, modified, or removed ranges on the new side |
83
+ | `rows` | Old and new lines paired for side-by-side display |
84
+
85
+ Use `hunk_at(new_line:)` or `mark_at(new_line:)` for position lookup,
86
+ `to_unified` for interoperable text, and `to_h` for serialization.
87
+
88
+ `Diff` snapshots its inputs and lazily memoizes these collections. Finish the
89
+ fields needed by another thread on the producing thread before sharing it.
34
90
 
91
+ ### Inline diff
92
+
93
+ ```ruby
35
94
  old_spans, new_spans = Porrima::Inline.refine("hello old", "hello new")
36
- patch = Porrima::Patch.parse(result.to_unified).first
37
- patch.apply("one\ntwo\n")
95
+ ```
38
96
 
39
- merge = Porrima::Merge.three_way(base: "old\n", ours: "ours\n", theirs: "theirs\n")
40
- Porrima::Merge.to_text(merge, style: :diff3)
97
+ Pass `granularity: :char` to compare grapheme clusters instead of words.
98
+
99
+ ### Patch
100
+
101
+ ```ruby
102
+ file = Porrima::Patch.parse(diff.to_unified).first
103
+ file.applies?(before) # => true
104
+ file.apply(before) # => "one\nchanged\n"
105
+ file.revert(after) # => "one\ntwo\n"
41
106
  ```
42
107
 
43
- `Porrima.edits`, `.hunks`, `.unified`, `.apply`, and `.revert` are available
44
- as lower-level entry points. `Porrima::Budget` can replace an oversized input
45
- as one block or raise `Porrima::BudgetExceeded`.
108
+ Patch application is exact: stale or malformed input raises a Porrima error.
46
109
 
47
- ## Data model
110
+ ### Three-way merge
48
111
 
49
- - `Edit` is an equal, deleted, or inserted line with old and new positions.
50
- - `Hunk` groups edits and exposes its exact old and new text.
51
- - `Mark` locates an added, modified, or removed block on the new side.
52
- - `Row` pairs old and new lines without presentation policy.
53
- - `Span` describes equal, deleted, or inserted inline text.
54
- - `Merge::Result` contains plain sections and structured conflicts; markers are
55
- only produced by `Merge.to_text`.
112
+ ```ruby
113
+ merge = Porrima::Merge.three_way(
114
+ base: "old\n",
115
+ ours: "ours\n",
116
+ theirs: "theirs\n"
117
+ )
118
+
119
+ merge.clean?
120
+ merge.conflicts
121
+ merge.regions # zero-based locations in merge-marker output
122
+ Porrima::Merge.to_text(merge, style: :diff3)
56
123
 
57
- `Diff` snapshots its inputs and lazily memoizes these collections. Finish the
58
- fields needed by another thread on the producing thread before handing the
59
- instance across.
124
+ resolved = Porrima::Merge.resolve(merge, 0, :ours)
125
+ Porrima::Merge.to_resolved_text(resolved) # => "ours\n"
126
+ Porrima::Merge.conflict_inline(merge.conflicts.first)
127
+ ```
128
+
129
+ `resolve` accepts `:ours`, `:theirs`, `:base`, `:ours_then_theirs`,
130
+ `:theirs_then_ours`, a replacement string, or an array of replacement strings.
131
+ It returns a new result and leaves the source result unchanged. Use `resolve_all`
132
+ to apply one choice to every conflict. `to_resolved_text` rejects results that
133
+ still contain a conflict.
60
134
 
61
135
  ## CLI
62
136
 
@@ -68,14 +142,24 @@ porrima apply [options] PATCH [FILE]
68
142
 
69
143
  Diff supports unified output, `--json`, `--quiet`, and `--max-bytes`. Merge
70
144
  supports structured JSON or `diff3`/`merge` markers. Apply supports
71
- `--reverse` and validation-only `--check`. Exit status is 0 for no
72
- diff/conflict, 1 for a diff/conflict, and 2 for an error.
145
+ `--reverse` and validation-only `--check`.
73
146
 
74
- ## Compatibility
147
+ Exit status is 0 for no diff or conflict, 1 for a diff or conflict, and 2 for
148
+ an error.
75
149
 
76
- Porrima requires CRuby 3.1 or newer. The line edit, hunk, unified output, and
77
- revert behavior are byte-compatible with the original Canopus diff engine.
78
- Public struct field names and order are part of the 0.1 contract.
150
+ ## Compatibility and limits
151
+
152
+ - Line edits, hunks, unified output, and revert behavior are byte-compatible
153
+ with the original Canopus diff engine.
154
+ - Public struct field names and order are part of the 0.1 contract.
155
+ - Comparison uses `String#==`; callers own encoding normalization.
156
+ - Patch application intentionally has no fuzz matching.
157
+ - Inline refinement treats over 2,000 combined tokens as one replacement.
158
+ - Merge region positions are zero-based and cover the complete conflict marker
159
+ block produced by `to_text(style: :merge)`.
160
+ - `Porrima::Budget` can replace an oversized input as one block or raise
161
+ `Porrima::BudgetExceeded`.
162
+ - During 0.x releases, minor versions may contain breaking changes.
79
163
 
80
164
  ## Development
81
165
 
@@ -88,14 +172,11 @@ bundle exec rake bench:assert
88
172
  gem build --strict porrima.gemspec
89
173
  ```
90
174
 
91
- ## Limits
175
+ ## Contributing
92
176
 
93
- - Comparison uses `String#==`; callers own encoding normalization.
94
- - Patch application is exact and intentionally has no fuzz matching.
95
- - Inline refinement treats over 2,000 combined tokens as a whole-text replacement.
96
- - `Diff` memoization is not synchronized; complete it before cross-thread use.
97
- - During 0.x releases, minor versions may contain breaking changes.
177
+ Bug reports and pull requests are welcome on
178
+ [GitHub](https://github.com/noxdea/porrima).
98
179
 
99
180
  ## License
100
181
 
101
- Porrima is released under the [MIT License](LICENSE.txt).
182
+ Porrima is available under the [MIT License](LICENSE.txt).
data/lib/porrima/merge.rb CHANGED
@@ -4,17 +4,56 @@ module Porrima
4
4
  module Merge
5
5
  Change = Struct.new(:start, :finish, :replacement, :side, keyword_init: true)
6
6
  Conflict = Struct.new(:base_start, :base_count, :base, :ours, :theirs, keyword_init: true)
7
+ Region = Struct.new(:conflict, :output_start, :output_count, :index, keyword_init: true)
8
+ RESOLUTION_CHOICES = %i[ours theirs base ours_then_theirs theirs_then_ours].freeze
9
+ private_constant :RESOLUTION_CHOICES
7
10
 
8
11
  class Result
9
12
  attr_reader :sections, :conflicts
10
13
 
11
14
  def initialize(sections)
12
- @sections = sections.freeze
13
- @conflicts = sections.grep(Conflict).freeze
15
+ raise ArgumentError, "sections must be an array" unless sections.is_a?(Array)
16
+ @sections = sections.map { |section| snapshot(section) }.freeze
17
+ @conflicts = @sections.grep(Conflict).freeze
14
18
  freeze
15
19
  end
16
20
 
17
21
  def clean? = conflicts.empty?
22
+ def resolved? = conflicts.empty?
23
+
24
+ def regions
25
+ output_line = conflict_index = 0
26
+ sections.each_with_object([]) do |section, regions|
27
+ unless section.is_a?(Conflict)
28
+ output_line += section.count("\n")
29
+ next
30
+ end
31
+
32
+ marked = +""
33
+ Merge.send(:append_conflict_text, marked, section, :merge, ["ours", "base", "theirs"])
34
+ output_count = marked.count("\n")
35
+ regions << Region.new(conflict: section, output_start: output_line,
36
+ output_count: output_count, index: conflict_index).freeze
37
+ output_line += output_count
38
+ conflict_index += 1
39
+ end.freeze
40
+ end
41
+
42
+ private
43
+
44
+ def snapshot(section)
45
+ return section.dup.freeze if section.is_a?(String)
46
+ raise ArgumentError, "sections must contain strings or conflicts" unless section.is_a?(Conflict)
47
+ unless section.base_start.is_a?(Integer) && section.base_start >= 0 &&
48
+ section.base_count.is_a?(Integer) && section.base_count >= 0 &&
49
+ [section.base, section.ours, section.theirs].all?(String)
50
+ raise ArgumentError, "invalid conflict"
51
+ end
52
+
53
+ Conflict.new(base_start: section.base_start, base_count: section.base_count,
54
+ base: section.base.dup.freeze, ours: section.ours.dup.freeze,
55
+ theirs: section.theirs.dup.freeze).freeze
56
+ end
18
57
  end
19
58
 
20
59
  module_function
@@ -65,16 +104,38 @@ module Porrima
65
104
  output << section
66
105
  next
67
106
  end
68
- output << "<<<<<<< #{labels[0]}\n"
69
- append_marked_text(output, section.ours)
70
- if style == :diff3
71
- output << "||||||| #{labels[1]}\n"
72
- append_marked_text(output, section.base)
73
- end
74
- output << "=======\n"
75
- append_marked_text(output, section.theirs)
76
- output << ">>>>>>> #{labels[2]}\n"
107
+ append_conflict_text(output, section, style, labels)
108
+ end
109
+ end
110
+
111
+ def resolve(result, index, choice)
112
+ validate_result(result)
113
+ unless index.is_a?(Integer) && index.between?(0, result.conflicts.length - 1)
114
+ raise ArgumentError, "index must identify a conflict"
77
115
  end
116
+ validate_choice(choice)
117
+ conflict = result.conflicts[index]
118
+ Result.new(result.sections.map { |section| section.equal?(conflict) ? resolution_text(conflict, choice) : section })
119
+ end
120
+
121
+ def resolve_all(result, choice)
122
+ validate_result(result)
123
+ validate_choice(choice)
124
+ Result.new(result.sections.map { |section| section.is_a?(Conflict) ? resolution_text(section, choice) : section })
125
+ end
126
+
127
+ def to_resolved_text(result)
128
+ validate_result(result)
129
+ raise ArgumentError, "merge result has unresolved conflicts" unless result.resolved?
130
+ result.sections.join
131
+ end
132
+
133
+ def conflict_inline(conflict)
134
+ unless conflict.is_a?(Conflict) && [conflict.ours, conflict.theirs].all?(String)
135
+ raise ArgumentError, "conflict must contain ours and theirs text"
136
+ end
137
+ ours, theirs = Inline.refine(conflict.ours, conflict.theirs)
138
+ {ours: ours, theirs: theirs}
78
139
  end
79
140
 
80
141
  def lines(text) = text.is_a?(String) ? text.lines : text
@@ -130,5 +191,49 @@ module Porrima
130
191
  output << "\n" unless text.empty? || text.end_with?("\n")
131
192
  end
132
193
  private_class_method :append_marked_text
194
+
195
+ def append_conflict_text(output, conflict, style, labels)
196
+ output << "<<<<<<< #{labels[0]}\n"
197
+ append_marked_text(output, conflict.ours)
198
+ if style == :diff3
199
+ output << "||||||| #{labels[1]}\n"
200
+ append_marked_text(output, conflict.base)
201
+ end
202
+ output << "=======\n"
203
+ append_marked_text(output, conflict.theirs)
204
+ output << ">>>>>>> #{labels[2]}\n"
205
+ end
206
+ private_class_method :append_conflict_text
207
+
208
+ def validate_result(result)
209
+ raise ArgumentError, "result must be a Porrima::Merge::Result" unless result.is_a?(Result)
210
+ end
211
+ private_class_method :validate_result
212
+
213
+ def validate_choice(choice)
214
+ valid = RESOLUTION_CHOICES.include?(choice) || choice.is_a?(String) ||
215
+ (choice.is_a?(Array) && choice.all?(String))
216
+ raise ArgumentError, "invalid resolution choice" unless valid
217
+ end
218
+ private_class_method :validate_choice
219
+
220
+ def resolution_text(conflict, choice)
221
+ case choice
222
+ when :ours then conflict.ours
223
+ when :theirs then conflict.theirs
224
+ when :base then conflict.base
225
+ when :ours_then_theirs then join_alternatives(conflict.ours, conflict.theirs)
226
+ when :theirs_then_ours then join_alternatives(conflict.theirs, conflict.ours)
227
+ when Array then choice.join
228
+ else choice
229
+ end
230
+ end
231
+ private_class_method :resolution_text
232
+
233
+ def join_alternatives(first, second)
234
+ return first + second if first.empty? || second.empty? || first.end_with?("\n")
235
+ first + "\n" + second
236
+ end
237
+ private_class_method :join_alternatives
133
238
  end
134
239
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Porrima
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/sig/porrima.rbs CHANGED
@@ -2,6 +2,7 @@ module Porrima
2
2
  VERSION: String
3
3
  type lines = String | Array[String]
4
4
  type kind = :equal | :delete | :insert
5
+ type merge_choice = :ours | :theirs | :base | :ours_then_theirs | :theirs_then_ours | String | Array[String]
5
6
 
6
7
  class Error < StandardError
7
8
  end
@@ -110,14 +111,27 @@ module Porrima
110
111
  attr_accessor theirs: String
111
112
  def self.new: (base_start: Integer, base_count: Integer, base: String, ours: String, theirs: String) -> instance
112
113
  end
114
+ class Region < Struct[untyped]
115
+ attr_accessor conflict: Conflict
116
+ attr_accessor output_start: Integer
117
+ attr_accessor output_count: Integer
118
+ attr_accessor index: Integer
119
+ def self.new: (conflict: Conflict, output_start: Integer, output_count: Integer, index: Integer) -> instance
120
+ end
113
121
  class Result
114
122
  attr_reader sections: Array[String | Conflict]
115
123
  attr_reader conflicts: Array[Conflict]
116
124
  def initialize: (Array[String | Conflict]) -> void
117
125
  def clean?: () -> bool
126
+ def resolved?: () -> bool
127
+ def regions: () -> Array[Region]
118
128
  end
119
129
  def self.three_way: (base: String, ours: String, theirs: String) -> Result
120
130
  def self.to_text: (Result, ?style: (:diff3 | :merge), ?labels: Array[String]) -> String
131
+ def self.resolve: (Result, Integer, merge_choice) -> Result
132
+ def self.resolve_all: (Result, merge_choice) -> Result
133
+ def self.to_resolved_text: (Result) -> String
134
+ def self.conflict_inline: (Conflict) -> { ours: Array[Span], theirs: Array[Span] }
121
135
  end
122
136
 
123
137
  def self.diff: (lines, lines, ?context: Integer, ?budget: Budget?) -> Diff
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: porrima
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -10,7 +10,8 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Linear-space Myers diff with context hunks, gutter marks, paired rows,
13
- unified output, strict patch application, and three-way merge. No I/O, no dependencies.
13
+ unified output, strict patch application, and resolvable three-way merge. No I/O,
14
+ no dependencies.
14
15
  email:
15
16
  - t.yudai92@gmail.com
16
17
  executables:
@@ -58,7 +59,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
58
59
  - !ruby/object:Gem::Version
59
60
  version: '0'
60
61
  requirements: []
61
- rubygems_version: 4.0.19
62
+ rubygems_version: 3.6.9
62
63
  specification_version: 4
63
64
  summary: Line, word, and three-way diff, patch, and merge in pure Ruby
64
65
  test_files: []