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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/exe/porrima +12 -0
- data/lib/porrima/budget.rb +25 -0
- data/lib/porrima/cli.rb +136 -0
- data/lib/porrima/diff.rb +105 -0
- data/lib/porrima/edit.rb +13 -0
- data/lib/porrima/errors.rb +7 -0
- data/lib/porrima/hunks.rb +27 -0
- data/lib/porrima/inline.rb +46 -0
- data/lib/porrima/merge.rb +134 -0
- data/lib/porrima/myers.rb +53 -0
- data/lib/porrima/patch.rb +96 -0
- data/lib/porrima/unified.rb +16 -0
- data/lib/porrima/version.rb +5 -0
- data/lib/porrima.rb +67 -0
- data/sig/porrima.rbs +130 -0
- metadata +64 -0
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
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
|
+
[](https://rubygems.org/gems/porrima)
|
|
4
|
+
[](https://github.com/noxdea/porrima/actions/workflows/main.yml)
|
|
5
|
+
[](porrima.gemspec)
|
|
6
|
+
[](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
|
data/lib/porrima/cli.rb
ADDED
|
@@ -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
|
data/lib/porrima/diff.rb
ADDED
|
@@ -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
|
data/lib/porrima/edit.rb
ADDED
|
@@ -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,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
|
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: []
|