porrima 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a343ef7bf35f119ee70c360d6fb3f4d69e20e052911cbd8f449ea87964b40284
4
+ data.tar.gz: fabc56760d330596512fa8481359dcc364a7793af4e8984e98a920b230c6b052
5
+ SHA512:
6
+ metadata.gz: 48f5568c01680dab34f4633161d19d3e34486e2b8e7299ddf31eb40765fabe57f7ea796c73b11a39686bc3237a4c52d70c83422f842c98f94aefe90b681c66d6
7
+ data.tar.gz: 46430b633bdc526768d2f186d2f73d6030a7d764a97c65692033246204441ead795faacb391bdd24652de5231cd659d63a617aa03b65b5a8e38fb4a142733637
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-12
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
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.
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ gem install porrima
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```ruby
26
+ require "porrima"
27
+
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")
34
+
35
+ 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")
38
+
39
+ merge = Porrima::Merge.three_way(base: "old\n", ours: "ours\n", theirs: "theirs\n")
40
+ Porrima::Merge.to_text(merge, style: :diff3)
41
+ ```
42
+
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`.
46
+
47
+ ## Data model
48
+
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`.
56
+
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.
60
+
61
+ ## CLI
62
+
63
+ ```text
64
+ porrima [diff] [options] OLD NEW
65
+ porrima merge [options] BASE OURS THEIRS
66
+ porrima apply [options] PATCH [FILE]
67
+ ```
68
+
69
+ Diff supports unified output, `--json`, `--quiet`, and `--max-bytes`. Merge
70
+ 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.
73
+
74
+ ## Compatibility
75
+
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.
79
+
80
+ ## Development
81
+
82
+ ```sh
83
+ bundle install
84
+ bundle exec rake
85
+ ruby tools/check_isolation.rb
86
+ bundle exec rbs -I sig validate
87
+ bundle exec rake bench:assert
88
+ gem build --strict porrima.gemspec
89
+ ```
90
+
91
+ ## Limits
92
+
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.
98
+
99
+ ## License
100
+
101
+ Porrima is released under the [MIT License](LICENSE.txt).
data/exe/porrima ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rbconfig"
5
+ require "rubygems"
6
+ unless RUBY_ENGINE == "ruby" && (RUBY_VERSION.split(".").first(2).map(&:to_i) <=> [3, 1]) >= 0
7
+ abort "Porrima requires CRuby 3.1 or newer."
8
+ end
9
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
10
+ require "porrima"
11
+ require "porrima/cli"
12
+ exit Porrima::CLI.main(ARGV)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ class Budget
5
+ attr_reader :max_bytes, :max_lines, :on_exceeded
6
+
7
+ def initialize(max_bytes: nil, max_lines: nil, on_exceeded: :replace)
8
+ raise ArgumentError, "max_bytes must be nonnegative" unless max_bytes.nil? || max_bytes.is_a?(Integer) && max_bytes >= 0
9
+ raise ArgumentError, "max_lines must be nonnegative" unless max_lines.nil? || max_lines.is_a?(Integer) && max_lines >= 0
10
+ raise ArgumentError, "on_exceeded must be :replace or :raise" unless %i[replace raise].include?(on_exceeded)
11
+ @max_bytes, @max_lines, @on_exceeded = max_bytes, max_lines, on_exceeded
12
+ freeze
13
+ end
14
+
15
+ def exceeded?(before, after)
16
+ exceeded = max_lines && before.length + after.length > max_lines
17
+ exceeded ||= max_bytes && before.sum(&:bytesize) + after.sum(&:bytesize) > max_bytes
18
+ return false unless exceeded
19
+ raise BudgetExceeded, "diff budget exceeded" if on_exceeded == :raise
20
+ true
21
+ end
22
+
23
+ NONE = new
24
+ end
25
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "tempfile"
6
+ require "fileutils"
7
+
8
+ module Porrima
9
+ module CLI
10
+ MAX_BYTES = 10 * 1024 * 1024
11
+ module_function
12
+
13
+ def main(argv, output: $stdout, error: $stderr)
14
+ arguments = argv.dup
15
+ command = %w[diff merge apply].include?(arguments.first) ? arguments.shift : "diff"
16
+ send("run_#{command}", arguments, output)
17
+ rescue OptionParser::ParseError, StandardError => exception
18
+ error.puts "porrima: #{exception.message}"
19
+ 2
20
+ end
21
+
22
+ def run_diff(arguments, output)
23
+ options = {context: 3, json: false, quiet: false, max_bytes: MAX_BYTES}
24
+ parser = OptionParser.new do |command|
25
+ command.banner = "Usage: porrima [diff] [options] OLD NEW"
26
+ command.on("-u", "--unified N", Integer) { |value| options[:context] = value }
27
+ command.on("--json") { options[:json] = true }
28
+ command.on("-q", "--quiet") { options[:quiet] = true }
29
+ command.on("--max-bytes N", Integer) { |value| options[:max_bytes] = value }
30
+ command.on("--version") { output.puts VERSION; return 0 }
31
+ command.on("--help") { output.puts command; return 0 }
32
+ end
33
+ parser.parse!(arguments)
34
+ raise OptionParser::MissingArgument, "OLD NEW" unless arguments.length == 2
35
+ budget = Budget.new(max_bytes: options[:max_bytes], on_exceeded: :raise)
36
+ result = Porrima.diff(File.binread(arguments[0]), File.binread(arguments[1]), context: options[:context], budget: budget)
37
+ unless options[:quiet]
38
+ output.puts JSON.generate(result.to_h) if options[:json]
39
+ output << result.to_unified(old_name: arguments[0], new_name: arguments[1]) unless options[:json] || result.empty?
40
+ end
41
+ result.empty? ? 0 : 1
42
+ end
43
+ private_class_method :run_diff
44
+
45
+ def run_merge(arguments, output)
46
+ options = {style: :diff3, json: false, quiet: false, max_bytes: MAX_BYTES}
47
+ parser = OptionParser.new do |command|
48
+ command.banner = "Usage: porrima merge [options] BASE OURS THEIRS"
49
+ command.on("--style STYLE", %w[diff3 merge]) { |value| options[:style] = value.to_sym }
50
+ command.on("--json") { options[:json] = true }
51
+ command.on("-q", "--quiet") { options[:quiet] = true }
52
+ command.on("--max-bytes N", Integer) { |value| options[:max_bytes] = value }
53
+ command.on("--version") { output.puts VERSION; return 0 }
54
+ command.on("--help") { output.puts command; return 0 }
55
+ end
56
+ parser.parse!(arguments)
57
+ raise OptionParser::MissingArgument, "BASE OURS THEIRS" unless arguments.length == 3
58
+ texts = arguments.map { |path| File.binread(path) }
59
+ raise BudgetExceeded, "diff budget exceeded" if texts.sum(&:bytesize) > options[:max_bytes]
60
+ result = Merge.three_way(base: texts[0], ours: texts[1], theirs: texts[2])
61
+ unless options[:quiet]
62
+ output.puts JSON.generate(merge_to_h(result)) if options[:json]
63
+ output << Merge.to_text(result, style: options[:style], labels: [arguments[1], arguments[0], arguments[2]]) unless options[:json]
64
+ end
65
+ result.clean? ? 0 : 1
66
+ end
67
+ private_class_method :run_merge
68
+
69
+ def run_apply(arguments, output)
70
+ options = {reverse: false, check: false, quiet: false, max_bytes: MAX_BYTES}
71
+ parser = OptionParser.new do |command|
72
+ command.banner = "Usage: porrima apply [options] PATCH [FILE]"
73
+ command.on("--reverse") { options[:reverse] = true }
74
+ command.on("--check") { options[:check] = true }
75
+ command.on("-q", "--quiet") { options[:quiet] = true }
76
+ command.on("--max-bytes N", Integer) { |value| options[:max_bytes] = value }
77
+ command.on("--version") { output.puts VERSION; return 0 }
78
+ command.on("--help") { output.puts command; return 0 }
79
+ end
80
+ parser.parse!(arguments)
81
+ raise OptionParser::MissingArgument, "PATCH [FILE]" unless (1..2).cover?(arguments.length)
82
+ patch_text = File.binread(arguments[0])
83
+ raise BudgetExceeded, "diff budget exceeded" if patch_text.bytesize > options[:max_bytes]
84
+ files = Patch.parse(patch_text)
85
+ raise PatchError, "FILE is required for a multi-file patch" if arguments[1] && files.length != 1
86
+ updates = files.map do |file|
87
+ path = arguments[1] || patch_path(file, options[:reverse])
88
+ content = File.file?(path) ? File.binread(path) : ""
89
+ raise BudgetExceeded, "diff budget exceeded" if patch_text.bytesize + content.bytesize > options[:max_bytes]
90
+ updated = options[:reverse] ? file.revert(content) : file.apply(content)
91
+ [path, updated, options[:reverse] ? file.old_name == "/dev/null" : file.new_name == "/dev/null"]
92
+ end
93
+ unless options[:check]
94
+ updates.each { |path, content, delete| delete ? File.unlink(path) : atomic_write(path, content) }
95
+ end
96
+ updates.each { |path,| output.puts path } unless options[:quiet]
97
+ 0
98
+ end
99
+ private_class_method :run_apply
100
+
101
+ def patch_path(file, reverse)
102
+ name = if reverse
103
+ file.new_name == "/dev/null" ? file.old_name : file.new_name
104
+ else
105
+ file.old_name == "/dev/null" ? file.new_name : file.old_name
106
+ end
107
+ name = name.delete_prefix("a/").delete_prefix("b/")
108
+ raise PatchError, "unsafe patch path" if name.empty? || name.start_with?("/") || name.split("/").any? { |part| ["", ".", "..", ".git"].include?(part) }
109
+ name
110
+ end
111
+ private_class_method :patch_path
112
+
113
+ def atomic_write(path, content)
114
+ directory = File.dirname(File.expand_path(path))
115
+ FileUtils.mkdir_p(directory)
116
+ mode = File.file?(path) ? File.stat(path).mode & 0o777 : 0o644
117
+ Tempfile.create([".porrima-", ".tmp"], directory) do |file|
118
+ file.binmode
119
+ file.chmod(mode)
120
+ file.write(content)
121
+ file.flush
122
+ file.fsync
123
+ file.close
124
+ File.rename(file.path, path)
125
+ end
126
+ end
127
+ private_class_method :atomic_write
128
+
129
+ def merge_to_h(result)
130
+ {version: 1, clean: result.clean?, sections: result.sections.map do |section|
131
+ section.is_a?(Merge::Conflict) ? section.to_h.merge(kind: :conflict) : {kind: :text, text: section}
132
+ end}
133
+ end
134
+ private_class_method :merge_to_h
135
+ end
136
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ class Diff
5
+ def initialize(before, after, context: 3, budget: nil)
6
+ @before = snapshot(before)
7
+ @after = snapshot(after)
8
+ @context = context
9
+ @budget = budget
10
+ @memo = {}
11
+ freeze
12
+ end
13
+
14
+ def edits = @memo[:edits] ||= Porrima.edits(@before, @after, budget: @budget).each(&:freeze).freeze
15
+
16
+ def hunks
17
+ @memo[:hunks] ||= Porrima.hunks(@before, @after, context: @context, budget: @budget).each do |hunk|
18
+ hunk.edits.each(&:freeze).freeze
19
+ hunk.freeze
20
+ end.freeze
21
+ end
22
+
23
+ def marks = @memo[:marks] ||= build_marks.freeze
24
+ def rows = @memo[:rows] ||= build_rows.freeze
25
+
26
+ def stat
27
+ @memo[:stat] ||= Stat.new(insertions: edits.count { |edit| edit.kind == :insert },
28
+ deletions: edits.count { |edit| edit.kind == :delete }, hunks: hunks.length).freeze
29
+ end
30
+
31
+ def hunk_at(new_line:)
32
+ left = 0
33
+ right = hunks.length - 1
34
+ while left <= right
35
+ middle = (left + right) / 2
36
+ hunk = hunks[middle]
37
+ first = [hunk.new_start, 1].max
38
+ last = [hunk.new_start + hunk.new_count - 1, hunk.new_start, 1].max
39
+ return hunk if new_line.between?(first, last)
40
+ new_line < first ? right = middle - 1 : left = middle + 1
41
+ end
42
+ nil
43
+ end
44
+
45
+ def mark_at(new_line:)
46
+ marks.find do |mark|
47
+ mark.kind == :removed ? new_line == mark.new_line : new_line.between?(mark.new_line, mark.new_line + mark.count - 1)
48
+ end
49
+ end
50
+
51
+ def empty? = Porrima.identical?(@before, @after)
52
+
53
+ def to_unified(old_name: "a/file", new_name: "b/file")
54
+ Porrima.unified(@before, @after, old_name: old_name, new_name: new_name, context: @context, budget: @budget)
55
+ end
56
+
57
+ def to_h
58
+ {version: 1, edits: edits.map(&:to_h), hunks: hunks.map { |hunk| hunk.to_h.merge(edits: hunk.edits.map(&:to_h)) },
59
+ stat: stat.to_h, marks: marks.map(&:to_h), rows: rows.map(&:to_h)}
60
+ end
61
+
62
+ private
63
+
64
+ def snapshot(value)
65
+ return value.dup.freeze if value.is_a?(String)
66
+ value.map { |item| item.dup.freeze }.freeze
67
+ end
68
+
69
+ def build_marks
70
+ Porrima.hunks(@before, @after, context: 0, budget: @budget).map do |hunk|
71
+ insertions = hunk.edits.count { |edit| edit.kind == :insert }
72
+ deletions = hunk.edits.count { |edit| edit.kind == :delete }
73
+ kind = insertions.positive? ? (deletions.positive? ? :modified : :added) : :removed
74
+ Mark.new(new_line: hunk.new_start, kind: kind, count: insertions.positive? ? insertions : deletions).freeze
75
+ end
76
+ end
77
+
78
+ def build_rows
79
+ result = []
80
+ index = 0
81
+ while index < edits.length
82
+ edit = edits[index]
83
+ if edit.kind == :equal
84
+ result << Row.new(kind: :equal, old_line: edit.old_line, new_line: edit.new_line, old_text: edit.text, new_text: edit.text).freeze
85
+ index += 1
86
+ next
87
+ end
88
+ changed = []
89
+ while index < edits.length && edits[index].kind != :equal
90
+ changed << edits[index]
91
+ index += 1
92
+ end
93
+ deleted = changed.select { |item| item.kind == :delete }
94
+ inserted = changed.select { |item| item.kind == :insert }
95
+ [deleted.length, inserted.length].max.times do |offset|
96
+ old, new = deleted[offset], inserted[offset]
97
+ kind = old && new ? :modified : old ? :removed : :added
98
+ result << Row.new(kind: kind, old_line: old&.old_line, new_line: new&.new_line,
99
+ old_text: old&.text, new_text: new&.text).freeze
100
+ end
101
+ end
102
+ result
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ Edit = Struct.new(:kind, :old_line, :new_line, :text, keyword_init: true)
5
+ Hunk = Struct.new(:old_start, :old_count, :new_start, :new_count, :edits, keyword_init: true) do
6
+ def old_text = edits.reject { |edit| edit.kind == :insert }.map(&:text).join
7
+ def new_text = edits.reject { |edit| edit.kind == :delete }.map(&:text).join
8
+ end
9
+ Mark = Struct.new(:new_line, :kind, :count, keyword_init: true)
10
+ Row = Struct.new(:kind, :old_line, :new_line, :old_text, :new_text, keyword_init: true)
11
+ Stat = Struct.new(:insertions, :deletions, :hunks, keyword_init: true)
12
+ Span = Struct.new(:kind, :text, keyword_init: true)
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ class Error < StandardError; end
5
+ class PatchError < Error; end
6
+ class BudgetExceeded < Error; end
7
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ def hunks(before, after, context: 3, budget: nil)
5
+ raise ArgumentError, "context must be nonnegative" unless context.is_a?(Integer) && context >= 0
6
+ changes = edits(before, after, budget: budget)
7
+ ranges = []
8
+ changes.each_with_index do |edit, index|
9
+ next if edit.kind == :equal
10
+ first = [0, index - context].max
11
+ last = [changes.length - 1, index + context].min
12
+ if ranges.last && first <= ranges.last[1] + 1
13
+ ranges.last[1] = last
14
+ else
15
+ ranges << [first, last]
16
+ end
17
+ end
18
+ ranges.map do |first, last|
19
+ items = changes[first..last]
20
+ old_count = items.count { |edit| edit.kind != :insert }
21
+ new_count = items.count { |edit| edit.kind != :delete }
22
+ Hunk.new(old_start: items.first.old_line - (old_count.zero? ? 1 : 0), old_count: old_count,
23
+ new_start: items.first.new_line - (new_count.zero? ? 1 : 0), new_count: new_count, edits: items)
24
+ end
25
+ end
26
+ module_function :hunks
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ module Inline
5
+ TOKEN_LIMIT = 2_000
6
+ module_function
7
+
8
+ def refine(old_text, new_text, granularity: :word)
9
+ old_tokens = tokenize(old_text, granularity)
10
+ new_tokens = tokenize(new_text, granularity)
11
+ return replacement(old_text, new_text) if old_tokens.length + new_tokens.length > TOKEN_LIMIT
12
+ changes = Porrima.edits(old_tokens, new_tokens)
13
+ [spans(changes.reject { |edit| edit.kind == :insert }), spans(changes.reject { |edit| edit.kind == :delete })]
14
+ end
15
+
16
+ def refine_row(row, granularity: :word)
17
+ refine(row.old_text.to_s, row.new_text.to_s, granularity: granularity)
18
+ end
19
+
20
+ def tokenize(text, granularity)
21
+ case granularity
22
+ when :word then text.scan(/\w+|\s+|./)
23
+ when :char then text.grapheme_clusters
24
+ else raise ArgumentError, "granularity must be :word or :char"
25
+ end
26
+ end
27
+ private_class_method :tokenize
28
+
29
+ def spans(edits)
30
+ edits.each_with_object([]) do |edit, result|
31
+ if result.last&.kind == edit.kind
32
+ result.last.text << edit.text
33
+ else
34
+ result << Span.new(kind: edit.kind, text: edit.text.dup)
35
+ end
36
+ end
37
+ end
38
+ private_class_method :spans
39
+
40
+ def replacement(old_text, new_text)
41
+ [old_text.empty? ? [] : [Span.new(kind: :delete, text: old_text)],
42
+ new_text.empty? ? [] : [Span.new(kind: :insert, text: new_text)]]
43
+ end
44
+ private_class_method :replacement
45
+ end
46
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ module Merge
5
+ Change = Struct.new(:start, :finish, :replacement, :side, keyword_init: true)
6
+ Conflict = Struct.new(:base_start, :base_count, :base, :ours, :theirs, keyword_init: true)
7
+
8
+ class Result
9
+ attr_reader :sections, :conflicts
10
+
11
+ def initialize(sections)
12
+ @sections = sections.freeze
13
+ @conflicts = sections.grep(Conflict).freeze
14
+ freeze
15
+ end
16
+
17
+ def clean? = conflicts.empty?
18
+ end
19
+
20
+ module_function
21
+
22
+ def three_way(base:, ours:, theirs:)
23
+ return Result.new([ours]) if ours == theirs || theirs == base
24
+ return Result.new([theirs]) if ours == base
25
+
26
+ base_lines = lines(base)
27
+ changes = side_changes(base, ours, :ours) + side_changes(base, theirs, :theirs)
28
+ changes.sort_by! { |change| [change.start, change.finish] }
29
+ sections = []
30
+ cursor = index = 0
31
+ while index < changes.length
32
+ cluster = [changes[index]]
33
+ index += 1
34
+ while index < changes.length && cluster.any? { |change| overlap?(change, changes[index]) }
35
+ cluster << changes[index]
36
+ index += 1
37
+ end
38
+ first = cluster.map(&:start).min
39
+ last = cluster.map(&:finish).max
40
+ append_text(sections, base_lines[cursor...first].join)
41
+ ours_changes = cluster.select { |change| change.side == :ours }
42
+ theirs_changes = cluster.select { |change| change.side == :theirs }
43
+ base_text = base_lines[first...last].join
44
+ ours_text = project(base_lines, first, last, ours_changes)
45
+ theirs_text = project(base_lines, first, last, theirs_changes)
46
+ if ours_changes.empty? || theirs_changes.empty? || ours_text == theirs_text
47
+ append_text(sections, ours_changes.empty? ? theirs_text : ours_text)
48
+ elsif ours_text == base_text || theirs_text == base_text
49
+ append_text(sections, ours_text == base_text ? theirs_text : ours_text)
50
+ else
51
+ append_conflict(sections, Conflict.new(base_start: first == last ? first : first + 1,
52
+ base_count: last - first, base: base_text, ours: ours_text, theirs: theirs_text).freeze)
53
+ end
54
+ cursor = last
55
+ end
56
+ append_text(sections, base_lines[cursor..].join)
57
+ Result.new(sections)
58
+ end
59
+
60
+ def to_text(result, style: :diff3, labels: ["ours", "base", "theirs"])
61
+ raise ArgumentError, "style must be :diff3 or :merge" unless %i[diff3 merge].include?(style)
62
+ raise ArgumentError, "labels must contain ours, base, and theirs" unless labels.is_a?(Array) && labels.length == 3 && labels.all?(String)
63
+ result.sections.each_with_object(+"") do |section, output|
64
+ unless section.is_a?(Conflict)
65
+ output << section
66
+ next
67
+ 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"
77
+ end
78
+ end
79
+
80
+ def lines(text) = text.is_a?(String) ? text.lines : text
81
+ private_class_method :lines
82
+
83
+ def side_changes(base, side, name)
84
+ Porrima.hunks(base, side, context: 0).map do |hunk|
85
+ start = hunk.old_count.zero? ? hunk.old_start : hunk.old_start - 1
86
+ Change.new(start: start, finish: start + hunk.old_count, replacement: hunk.new_text.lines, side: name)
87
+ end
88
+ end
89
+ private_class_method :side_changes
90
+
91
+ def overlap?(left, right)
92
+ left_insert = left.start == left.finish
93
+ right_insert = right.start == right.finish
94
+ return left.start == right.start if left_insert && right_insert
95
+ return right.start < left.finish && right.start > left.start if right_insert
96
+ return left.start < right.finish && left.start > right.start if left_insert
97
+ left.start < right.finish && right.start < left.finish
98
+ end
99
+ private_class_method :overlap?
100
+
101
+ def project(base, first, last, changes)
102
+ cursor = first
103
+ changes.sort_by(&:start).each_with_object([]) do |change, output|
104
+ output.concat(base[cursor...change.start])
105
+ output.concat(change.replacement)
106
+ cursor = change.finish
107
+ end.concat(base[cursor...last]).join
108
+ end
109
+ private_class_method :project
110
+
111
+ def append_text(sections, text)
112
+ return if text.empty?
113
+ sections.last.is_a?(String) ? sections.last << text : sections << +text
114
+ end
115
+ private_class_method :append_text
116
+
117
+ def append_conflict(sections, conflict)
118
+ previous = sections.last
119
+ unless previous.is_a?(Conflict)
120
+ sections << conflict
121
+ return
122
+ end
123
+ sections[-1] = Conflict.new(base_start: previous.base_start, base_count: previous.base_count + conflict.base_count,
124
+ base: previous.base + conflict.base, ours: previous.ours + conflict.ours, theirs: previous.theirs + conflict.theirs).freeze
125
+ end
126
+ private_class_method :append_conflict
127
+
128
+ def append_marked_text(output, text)
129
+ output << text
130
+ output << "\n" unless text.empty? || text.end_with?("\n")
131
+ end
132
+ private_class_method :append_marked_text
133
+ end
134
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ module_function
5
+
6
+ def script(left, right)
7
+ return right.map { |line| [:insert, line] } if left.empty?
8
+ return left.map { |line| [:delete, line] } if right.empty?
9
+ return left.map { |line| [:delete, line] } + right.map { |line| [:insert, line] } unless left.intersect?(right)
10
+ x, y = bisect(left, right)
11
+ if !x || (x.zero? && y.zero?) || (x == left.length && y == right.length)
12
+ return left.map { |line| [:delete, line] } + right.map { |line| [:insert, line] }
13
+ end
14
+ [edits(left[0...x], right[0...y]), edits(left[x..], right[y..])].flatten.map { |edit| [edit.kind, edit.text] }
15
+ end
16
+ private_class_method :script
17
+
18
+ def bisect(left, right)
19
+ n, m = left.length, right.length
20
+ limit = (n + m + 1) / 2
21
+ forward = {1 => 0}
22
+ reverse = {1 => 0}
23
+ delta = n - m
24
+ odd = delta.odd?
25
+ (0..limit).each do |depth|
26
+ (-depth..depth).step(2) do |k|
27
+ x = k == -depth || (k != depth && forward.fetch(k - 1, -1) < forward.fetch(k + 1, -1)) ? forward.fetch(k + 1, 0) : forward.fetch(k - 1, 0) + 1
28
+ y = x - k
29
+ while x < n && y < m && x >= 0 && y >= 0 && left[x] == right[y]
30
+ x += 1
31
+ y += 1
32
+ end
33
+ forward[k] = x
34
+ return [x, y] if odd && reverse.key?(delta - k) && x + reverse[delta - k] >= n
35
+ end
36
+ (-depth..depth).step(2) do |k|
37
+ x = k == -depth || (k != depth && reverse.fetch(k - 1, -1) < reverse.fetch(k + 1, -1)) ? reverse.fetch(k + 1, 0) : reverse.fetch(k - 1, 0) + 1
38
+ y = x - k
39
+ while x < n && y < m && x >= 0 && y >= 0 && left[n - x - 1] == right[m - y - 1]
40
+ x += 1
41
+ y += 1
42
+ end
43
+ reverse[k] = x
44
+ if !odd && forward.key?(delta - k) && forward[delta - k] + x >= n
45
+ middle_x = forward[delta - k]
46
+ return [middle_x, middle_x - (delta - k)]
47
+ end
48
+ end
49
+ end
50
+ nil
51
+ end
52
+ private_class_method :bisect
53
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ module Patch
5
+ FileDiff = Struct.new(:old_name, :new_name, :hunks, keyword_init: true) do
6
+ def applies?(text)
7
+ apply(text)
8
+ true
9
+ rescue PatchError
10
+ false
11
+ end
12
+
13
+ def apply(text)
14
+ hunks.reverse_each { |hunk| text = Porrima.apply(text, hunk) }
15
+ text
16
+ rescue ArgumentError => error
17
+ raise PatchError, error.message
18
+ end
19
+
20
+ def revert(text)
21
+ hunks.reverse_each { |hunk| text = Porrima.revert(text, hunk) }
22
+ text
23
+ rescue ArgumentError => error
24
+ raise PatchError, error.message
25
+ end
26
+ end
27
+
28
+ module_function
29
+
30
+ def parse(text)
31
+ lines = text.lines
32
+ files = []
33
+ index = 0
34
+ while index < lines.length
35
+ index += 1 while index < lines.length && !lines[index].start_with?("--- ")
36
+ break if index == lines.length
37
+ old_name = header_name(lines[index], "--- ")
38
+ index += 1
39
+ raise PatchError, "missing new file header" unless lines[index]&.start_with?("+++ ")
40
+ new_name = header_name(lines[index], "+++ ")
41
+ index += 1
42
+ hunks = []
43
+ while index < lines.length && lines[index].start_with?("@@ ")
44
+ hunk, index = parse_hunk(lines, index)
45
+ hunks << hunk
46
+ end
47
+ files << FileDiff.new(old_name: old_name, new_name: new_name, hunks: hunks)
48
+ end
49
+ raise PatchError, "malformed patch" if files.empty? && !text.empty?
50
+ files
51
+ rescue PatchError
52
+ raise
53
+ rescue StandardError => error
54
+ raise PatchError, error.message
55
+ end
56
+
57
+ def header_name(line, prefix)
58
+ line.delete_prefix(prefix).chomp.split("\t", 2).first
59
+ end
60
+ private_class_method :header_name
61
+
62
+ def parse_hunk(lines, index)
63
+ match = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.match(lines[index])
64
+ raise PatchError, "malformed hunk header" unless match
65
+ old_start, old_count, new_start, new_count = match.captures.map.with_index { |value, offset| value ? value.to_i : [1, 3].include?(offset) ? 1 : nil }
66
+ old_line = [old_start, 1].max
67
+ new_line = [new_start, 1].max
68
+ old_seen = new_seen = 0
69
+ edits = []
70
+ index += 1
71
+ until old_seen == old_count && new_seen == new_count
72
+ line = lines[index]
73
+ raise PatchError, "truncated hunk" unless line && [" ", "-", "+"].include?(line[0])
74
+ kind = {" " => :equal, "-" => :delete, "+" => :insert}.fetch(line[0])
75
+ body = line[1..]
76
+ index += 1
77
+ if lines[index]&.start_with?("\")
78
+ body = body.delete_suffix("\n")
79
+ index += 1
80
+ end
81
+ edits << Edit.new(kind: kind, old_line: old_line, new_line: new_line, text: body)
82
+ unless kind == :insert
83
+ old_line += 1
84
+ old_seen += 1
85
+ end
86
+ unless kind == :delete
87
+ new_line += 1
88
+ new_seen += 1
89
+ end
90
+ raise PatchError, "hunk line count mismatch" if old_seen > old_count || new_seen > new_count
91
+ end
92
+ [Hunk.new(old_start: old_start, old_count: old_count, new_start: new_start, new_count: new_count, edits: edits), index]
93
+ end
94
+ private_class_method :parse_hunk
95
+ end
96
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ def unified(before, after, old_name: "a/file", new_name: "b/file", context: 3, budget: nil)
5
+ output = +"--- #{old_name}\n+++ #{new_name}\n"
6
+ hunks(before, after, context: context, budget: budget).each do |hunk|
7
+ output << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
8
+ hunk.edits.each do |edit|
9
+ output << {equal: " ", delete: "-", insert: "+"}.fetch(edit.kind) << edit.text
10
+ output << "\n\\n" unless edit.text.end_with?("\n")
11
+ end
12
+ end
13
+ output
14
+ end
15
+ module_function :unified
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Porrima
4
+ VERSION = "0.1.0"
5
+ end
data/lib/porrima.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "porrima/version"
4
+ require_relative "porrima/errors"
5
+ require_relative "porrima/edit"
6
+ require_relative "porrima/budget"
7
+ require_relative "porrima/myers"
8
+ require_relative "porrima/hunks"
9
+ require_relative "porrima/unified"
10
+ require_relative "porrima/diff"
11
+ require_relative "porrima/patch"
12
+ require_relative "porrima/inline"
13
+ require_relative "porrima/merge"
14
+
15
+ module Porrima
16
+ module_function
17
+
18
+ def diff(before, after, context: 3, budget: nil) = Diff.new(before, after, context: context, budget: budget)
19
+
20
+ def edits(before, after, budget: nil)
21
+ left = before.is_a?(String) ? before.lines : before
22
+ right = after.is_a?(String) ? after.lines : after
23
+ if (budget || Budget::NONE).exceeded?(left, right)
24
+ return numbered(left.map { |line| [:delete, line] } + right.map { |line| [:insert, line] })
25
+ end
26
+
27
+ prefix = 0
28
+ prefix += 1 while prefix < left.length && prefix < right.length && left[prefix] == right[prefix]
29
+ suffix = 0
30
+ suffix += 1 while suffix < left.length - prefix && suffix < right.length - prefix && left[-suffix - 1] == right[-suffix - 1]
31
+ a = left.slice(prefix, left.length - prefix - suffix)
32
+ b = right.slice(prefix, right.length - prefix - suffix)
33
+ middle = script(a, b)
34
+ pairs = left.first(prefix).map { |line| [:equal, line] } + middle + (suffix.zero? ? [] : left.last(suffix).map { |line| [:equal, line] })
35
+ numbered(pairs)
36
+ end
37
+
38
+ def revert(text, hunk)
39
+ replace_hunk(text, hunk.new_start, hunk.new_count, hunk.new_text, hunk.old_text)
40
+ end
41
+
42
+ def apply(text, hunk)
43
+ replace_hunk(text, hunk.old_start, hunk.old_count, hunk.old_text, hunk.new_text)
44
+ end
45
+
46
+ def identical?(before, after) = before == after
47
+
48
+ def numbered(pairs)
49
+ old_line = new_line = 1
50
+ pairs.map do |kind, text|
51
+ edit = Edit.new(kind: kind, old_line: old_line, new_line: new_line, text: text)
52
+ old_line += 1 unless kind == :insert
53
+ new_line += 1 unless kind == :delete
54
+ edit
55
+ end
56
+ end
57
+ private_class_method :numbered
58
+
59
+ def replace_hunk(text, start_line, count, expected, replacement)
60
+ lines = text.lines
61
+ start = count.zero? ? start_line : start_line - 1
62
+ raise ArgumentError, "hunk no longer applies" unless lines.slice(start, count)&.join == expected
63
+ lines[start, count] = replacement.lines
64
+ lines.join
65
+ end
66
+ private_class_method :replace_hunk
67
+ end
data/sig/porrima.rbs ADDED
@@ -0,0 +1,130 @@
1
+ module Porrima
2
+ VERSION: String
3
+ type lines = String | Array[String]
4
+ type kind = :equal | :delete | :insert
5
+
6
+ class Error < StandardError
7
+ end
8
+ class PatchError < Error
9
+ end
10
+ class BudgetExceeded < Error
11
+ end
12
+
13
+ class Edit < Struct[untyped]
14
+ attr_accessor kind: kind
15
+ attr_accessor old_line: Integer
16
+ attr_accessor new_line: Integer
17
+ attr_accessor text: String
18
+ def self.new: (kind: kind, old_line: Integer, new_line: Integer, text: String) -> instance
19
+ end
20
+
21
+ class Hunk < Struct[untyped]
22
+ attr_accessor old_start: Integer
23
+ attr_accessor old_count: Integer
24
+ attr_accessor new_start: Integer
25
+ attr_accessor new_count: Integer
26
+ attr_accessor edits: Array[Edit]
27
+ def self.new: (old_start: Integer, old_count: Integer, new_start: Integer, new_count: Integer, edits: Array[Edit]) -> instance
28
+ def old_text: () -> String
29
+ def new_text: () -> String
30
+ end
31
+
32
+ class Mark < Struct[untyped]
33
+ attr_accessor new_line: Integer
34
+ attr_accessor kind: :added | :modified | :removed
35
+ attr_accessor count: Integer
36
+ def self.new: (new_line: Integer, kind: (:added | :modified | :removed), count: Integer) -> instance
37
+ end
38
+
39
+ class Row < Struct[untyped]
40
+ attr_accessor kind: :equal | :modified | :added | :removed
41
+ attr_accessor old_line: Integer?
42
+ attr_accessor new_line: Integer?
43
+ attr_accessor old_text: String?
44
+ attr_accessor new_text: String?
45
+ def self.new: (kind: (:equal | :modified | :added | :removed), old_line: Integer?, new_line: Integer?, old_text: String?, new_text: String?) -> instance
46
+ end
47
+
48
+ class Span < Struct[untyped]
49
+ attr_accessor kind: kind
50
+ attr_accessor text: String
51
+ def self.new: (kind: kind, text: String) -> instance
52
+ end
53
+
54
+ class Stat < Struct[untyped]
55
+ attr_accessor insertions: Integer
56
+ attr_accessor deletions: Integer
57
+ attr_accessor hunks: Integer
58
+ def self.new: (insertions: Integer, deletions: Integer, hunks: Integer) -> instance
59
+ end
60
+
61
+ class Budget
62
+ NONE: Budget
63
+ attr_reader max_bytes: Integer?
64
+ attr_reader max_lines: Integer?
65
+ attr_reader on_exceeded: :replace | :raise
66
+ def initialize: (?max_bytes: Integer?, ?max_lines: Integer?, ?on_exceeded: (:replace | :raise)) -> void
67
+ def exceeded?: (Array[String], Array[String]) -> bool
68
+ end
69
+
70
+ # Finish lazy fields on the producing thread before sharing an instance.
71
+ class Diff
72
+ def initialize: (lines, lines, ?context: Integer, ?budget: Budget?) -> void
73
+ def edits: () -> Array[Edit]
74
+ def hunks: () -> Array[Hunk]
75
+ def marks: () -> Array[Mark]
76
+ def rows: () -> Array[Row]
77
+ def stat: () -> Stat
78
+ def hunk_at: (new_line: Integer) -> Hunk?
79
+ def mark_at: (new_line: Integer) -> Mark?
80
+ def empty?: () -> bool
81
+ def to_unified: (?old_name: String, ?new_name: String) -> String
82
+ def to_h: () -> Hash[Symbol, untyped]
83
+ end
84
+
85
+ module Patch
86
+ class FileDiff < Struct[untyped]
87
+ attr_accessor old_name: String
88
+ attr_accessor new_name: String
89
+ attr_accessor hunks: Array[Hunk]
90
+ def self.new: (old_name: String, new_name: String, hunks: Array[Hunk]) -> instance
91
+ def applies?: (String) -> bool
92
+ def apply: (String) -> String
93
+ def revert: (String) -> String
94
+ end
95
+ def self.parse: (String) -> Array[FileDiff]
96
+ end
97
+
98
+ module Inline
99
+ TOKEN_LIMIT: Integer
100
+ def self.refine: (String, String, ?granularity: (:word | :char)) -> [Array[Span], Array[Span]]
101
+ def self.refine_row: (Row, ?granularity: (:word | :char)) -> [Array[Span], Array[Span]]
102
+ end
103
+
104
+ module Merge
105
+ class Conflict < Struct[untyped]
106
+ attr_accessor base_start: Integer
107
+ attr_accessor base_count: Integer
108
+ attr_accessor base: String
109
+ attr_accessor ours: String
110
+ attr_accessor theirs: String
111
+ def self.new: (base_start: Integer, base_count: Integer, base: String, ours: String, theirs: String) -> instance
112
+ end
113
+ class Result
114
+ attr_reader sections: Array[String | Conflict]
115
+ attr_reader conflicts: Array[Conflict]
116
+ def initialize: (Array[String | Conflict]) -> void
117
+ def clean?: () -> bool
118
+ end
119
+ def self.three_way: (base: String, ours: String, theirs: String) -> Result
120
+ def self.to_text: (Result, ?style: (:diff3 | :merge), ?labels: Array[String]) -> String
121
+ end
122
+
123
+ def self.diff: (lines, lines, ?context: Integer, ?budget: Budget?) -> Diff
124
+ def self.edits: (lines, lines, ?budget: Budget?) -> Array[Edit]
125
+ def self.hunks: (lines, lines, ?context: Integer, ?budget: Budget?) -> Array[Hunk]
126
+ def self.unified: (lines, lines, ?old_name: String, ?new_name: String, ?context: Integer, ?budget: Budget?) -> String
127
+ def self.revert: (String, Hunk) -> String
128
+ def self.apply: (String, Hunk) -> String
129
+ def self.identical?: (lines, lines) -> bool
130
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: porrima
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
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.
14
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables:
17
+ - porrima
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - exe/porrima
25
+ - lib/porrima.rb
26
+ - lib/porrima/budget.rb
27
+ - lib/porrima/cli.rb
28
+ - lib/porrima/diff.rb
29
+ - lib/porrima/edit.rb
30
+ - lib/porrima/errors.rb
31
+ - lib/porrima/hunks.rb
32
+ - lib/porrima/inline.rb
33
+ - lib/porrima/merge.rb
34
+ - lib/porrima/myers.rb
35
+ - lib/porrima/patch.rb
36
+ - lib/porrima/unified.rb
37
+ - lib/porrima/version.rb
38
+ - sig/porrima.rbs
39
+ homepage: https://github.com/noxdea/porrima
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ allowed_push_host: https://rubygems.org
44
+ source_code_uri: https://github.com/noxdea/porrima
45
+ changelog_uri: https://github.com/noxdea/porrima/blob/main/CHANGELOG.md
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.19
62
+ specification_version: 4
63
+ summary: Line, word, and three-way diff, patch, and merge in pure Ruby
64
+ test_files: []