From dbe41884bc7b00ccedd9699c5806ac5988291495 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sat, 1 Jul 2023 13:02:35 -0700 Subject: [PATCH 01/30] Add highlighter to packages --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitmodules b/.gitmodules index 2851e0573..46789ed14 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "plugin/Packages/TestEZ"] path = plugin/Packages/TestEZ url = https://github.com/roblox/testez.git +[submodule "plugin/Packages/Highlighter"] + path = plugin/Packages/Highlighter + url = https://github.com/boatbomber/highlighter.git From 88c55c57251bf2f78ac65ad0f8071862326276a8 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sat, 1 Jul 2023 13:06:28 -0700 Subject: [PATCH 02/30] Add highlighter submodule correctly --- plugin/Packages/Highlighter | 1 + 1 file changed, 1 insertion(+) create mode 160000 plugin/Packages/Highlighter diff --git a/plugin/Packages/Highlighter b/plugin/Packages/Highlighter new file mode 160000 index 000000000..2890275c6 --- /dev/null +++ b/plugin/Packages/Highlighter @@ -0,0 +1 @@ +Subproject commit 2890275c6bf20d00a21a2fe44f546b666e3ef530 From 603749c0f0406ab0804996dc79d8a19a01fac4a8 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sat, 1 Jul 2023 14:10:02 -0700 Subject: [PATCH 03/30] Support X scrolling --- plugin/src/App/Components/ScrollingFrame.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugin/src/App/Components/ScrollingFrame.lua b/plugin/src/App/Components/ScrollingFrame.lua index 2231aacb2..f3d11c8cc 100644 --- a/plugin/src/App/Components/ScrollingFrame.lua +++ b/plugin/src/App/Components/ScrollingFrame.lua @@ -23,19 +23,26 @@ local function ScrollingFrame(props) BottomImage = Assets.Images.ScrollBar.Bottom, ElasticBehavior = Enum.ElasticBehavior.Always, - ScrollingDirection = Enum.ScrollingDirection.Y, + ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y, Size = props.size, Position = props.position, AnchorPoint = props.anchorPoint, CanvasSize = props.contentSize:map(function(value) - return UDim2.new(0, 0, 0, value.Y) + return UDim2.new( + 0, + if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y) + then value.X + else 0, + 0, + value.Y + ) end), BorderSizePixel = 0, BackgroundTransparency = 1, - [Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize] + [Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize], }, props[Roact.Children]) end) end From 2dded358d76afc7f4aee722594cb73444b07b0bd Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sat, 1 Jul 2023 14:10:27 -0700 Subject: [PATCH 04/30] Add initial diff component --- .../StringDiffVisualizer/DiffMatchPatch.lua | 2102 +++++++++++++++++ .../Components/StringDiffVisualizer/init.lua | 230 ++ 2 files changed, 2332 insertions(+) create mode 100644 plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua create mode 100644 plugin/src/App/Components/StringDiffVisualizer/init.lua diff --git a/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua b/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua new file mode 100644 index 000000000..ba93ba1ee --- /dev/null +++ b/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua @@ -0,0 +1,2102 @@ +--[[ +* Diff Match and Patch +* Copyright 2018 The diff-match-patch Authors. +* https://github.com/google/diff-match-patch +* +* Based on the JavaScript implementation by Neil Fraser. +* Ported to Lua by Duncan Cross. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +--]] + +--[[ +-- Lua 5.1 and earlier requires the external BitOp library. +-- This library is built-in from Lua 5.2 and later as 'bit32'. +require 'bit' -- +local band, bor, lshift + = bit.band, bit.bor, bit.lshift +--]] + +local band, bor, lshift = bit32.band, bit32.bor, bit32.lshift +local type, setmetatable, ipairs, select = type, setmetatable, ipairs, select +local unpack, tonumber, error = unpack, tonumber, error +local strsub, strbyte, strchar, gmatch, gsub = string.sub, string.byte, string.char, string.gmatch, string.gsub +local strmatch, strfind, strformat = string.match, string.find, string.format +local tinsert, tremove, tconcat = table.insert, table.remove, table.concat +local max, min, floor, ceil, abs = math.max, math.min, math.floor, math.ceil, math.abs +local clock = os.clock + +-- Utility functions. + +local percentEncode_pattern = "[^A-Za-z0-9%-=;',./~!@#$%&*%(%)_%+ %?]" +local function percentEncode_replace(v) + return strformat("%%%02X", strbyte(v)) +end + +local function indexOf(a, b, start) + if #b == 0 then + return nil + end + return strfind(a, b, start, true) +end + +local htmlEncode_pattern = "[&<>\n]" +local htmlEncode_replace = { + ["&"] = "&", + ["<"] = "<", + [">"] = ">", + ["\n"] = "¶
", +} + +-- Public API Functions +-- (Exported at the end of the script) + +local diff_main, diff_cleanupSemantic, diff_cleanupEfficiency, diff_levenshtein, diff_prettyHtml + +local match_main + +local patch_make, patch_toText, patch_fromText, patch_apply + +--[[ +* The data structure representing a diff is an array of tuples: +* {{DIFF_DELETE, 'Hello'}, {DIFF_INSERT, 'Goodbye'}, {DIFF_EQUAL, ' world.'}} +* which means: delete 'Hello', add 'Goodbye' and keep ' world.' +--]] +local DIFF_DELETE = -1 +local DIFF_INSERT = 1 +local DIFF_EQUAL = 0 + +-- Number of seconds to map a diff before giving up (0 for infinity). +local Diff_Timeout = 1.0 +-- Cost of an empty edit operation in terms of edit characters. +local Diff_EditCost = 4 +-- At what point is no match declared (0.0 = perfection, 1.0 = very loose). +local Match_Threshold = 0.5 +-- How far to search for a match (0 = exact location, 1000+ = broad match). +-- A match this many characters away from the expected location will add +-- 1.0 to the score (0.0 is a perfect match). +local Match_Distance = 1000 +-- When deleting a large block of text (over ~64 characters), how close do +-- the contents have to be to match the expected contents. (0.0 = perfection, +-- 1.0 = very loose). Note that Match_Threshold controls how closely the +-- end points of a delete need to match. +local Patch_DeleteThreshold = 0.5 +-- Chunk size for context length. +local Patch_Margin = 4 +-- The number of bits in an int. +local Match_MaxBits = 32 + +function settings(new) + if new then + Diff_Timeout = new.Diff_Timeout or Diff_Timeout + Diff_EditCost = new.Diff_EditCost or Diff_EditCost + Match_Threshold = new.Match_Threshold or Match_Threshold + Match_Distance = new.Match_Distance or Match_Distance + Patch_DeleteThreshold = new.Patch_DeleteThreshold or Patch_DeleteThreshold + Patch_Margin = new.Patch_Margin or Patch_Margin + Match_MaxBits = new.Match_MaxBits or Match_MaxBits + else + return { + Diff_Timeout = Diff_Timeout, + Diff_EditCost = Diff_EditCost, + Match_Threshold = Match_Threshold, + Match_Distance = Match_Distance, + Patch_DeleteThreshold = Patch_DeleteThreshold, + Patch_Margin = Patch_Margin, + Match_MaxBits = Match_MaxBits, + } + end +end + +-- --------------------------------------------------------------------------- +-- DIFF API +-- --------------------------------------------------------------------------- + +-- The private diff functions +local _diff_compute, _diff_bisect, _diff_halfMatchI, _diff_halfMatch, _diff_cleanupSemanticScore, _diff_cleanupSemanticLossless, _diff_cleanupMerge, _diff_commonPrefix, _diff_commonSuffix, _diff_commonOverlap, _diff_xIndex, _diff_text1, _diff_text2, _diff_toDelta, _diff_fromDelta + +--[[ +* Find the differences between two texts. Simplifies the problem by stripping +* any common prefix or suffix off the texts before diffing. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {boolean} opt_checklines Has no effect in Lua. +* @param {number} opt_deadline Optional time when the diff should be complete +* by. Used internally for recursive calls. Users should set DiffTimeout +* instead. +* @return {Array.>} Array of diff tuples. +--]] +function diff_main(text1, text2, opt_checklines, opt_deadline) + -- Set a deadline by which time the diff must be complete. + if opt_deadline == nil then + if Diff_Timeout <= 0 then + opt_deadline = 2 ^ 31 + else + opt_deadline = clock() + Diff_Timeout + end + end + local deadline = opt_deadline + + -- Check for null inputs. + if text1 == nil or text2 == nil then + error("Null inputs. (diff_main)") + end + + -- Check for equality (speedup). + if text1 == text2 then + if #text1 > 0 then + return { { DIFF_EQUAL, text1 } } + end + return {} + end + + -- LUANOTE: Due to the lack of Unicode support, Lua is incapable of + -- implementing the line-mode speedup. + local checklines = false + + -- Trim off common prefix (speedup). + local commonlength = _diff_commonPrefix(text1, text2) + local commonprefix + if commonlength > 0 then + commonprefix = strsub(text1, 1, commonlength) + text1 = strsub(text1, commonlength + 1) + text2 = strsub(text2, commonlength + 1) + end + + -- Trim off common suffix (speedup). + commonlength = _diff_commonSuffix(text1, text2) + local commonsuffix + if commonlength > 0 then + commonsuffix = strsub(text1, -commonlength) + text1 = strsub(text1, 1, -commonlength - 1) + text2 = strsub(text2, 1, -commonlength - 1) + end + + -- Compute the diff on the middle block. + local diffs = _diff_compute(text1, text2, checklines, deadline) + + -- Restore the prefix and suffix. + if commonprefix then + tinsert(diffs, 1, { DIFF_EQUAL, commonprefix }) + end + if commonsuffix then + diffs[#diffs + 1] = { DIFF_EQUAL, commonsuffix } + end + + _diff_cleanupMerge(diffs) + return diffs +end + +--[[ +* Reduce the number of edits by eliminating semantically trivial equalities. +* @param {Array.>} diffs Array of diff tuples. +--]] +function diff_cleanupSemantic(diffs) + local changes = false + local equalities = {} -- Stack of indices where equalities are found. + local equalitiesLength = 0 -- Keeping our own length var is faster. + local lastEquality = nil + -- Always equal to diffs[equalities[equalitiesLength]][2] + local pointer = 1 -- Index of current position. + -- Number of characters that changed prior to the equality. + local length_insertions1 = 0 + local length_deletions1 = 0 + -- Number of characters that changed after the equality. + local length_insertions2 = 0 + local length_deletions2 = 0 + + while diffs[pointer] do + if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. + equalitiesLength = equalitiesLength + 1 + equalities[equalitiesLength] = pointer + length_insertions1 = length_insertions2 + length_deletions1 = length_deletions2 + length_insertions2 = 0 + length_deletions2 = 0 + lastEquality = diffs[pointer][2] + else -- An insertion or deletion. + if diffs[pointer][1] == DIFF_INSERT then + length_insertions2 = length_insertions2 + #diffs[pointer][2] + else + length_deletions2 = length_deletions2 + #diffs[pointer][2] + end + -- Eliminate an equality that is smaller or equal to the edits on both + -- sides of it. + if + lastEquality + and (#lastEquality <= max(length_insertions1, length_deletions1)) + and (#lastEquality <= max(length_insertions2, length_deletions2)) + then + -- Duplicate record. + tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) + -- Change second copy to insert. + diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT + -- Throw away the equality we just deleted. + equalitiesLength = equalitiesLength - 1 + -- Throw away the previous equality (it needs to be reevaluated). + equalitiesLength = equalitiesLength - 1 + pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 + length_insertions1, length_deletions1 = 0, 0 -- Reset the counters. + length_insertions2, length_deletions2 = 0, 0 + lastEquality = nil + changes = true + end + end + pointer = pointer + 1 + end + + -- Normalize the diff. + if changes then + _diff_cleanupMerge(diffs) + end + _diff_cleanupSemanticLossless(diffs) + + -- Find any overlaps between deletions and insertions. + -- e.g: abcxxxxxxdef + -- -> abcxxxdef + -- e.g: xxxabcdefxxx + -- -> defxxxabc + -- Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 2 + while diffs[pointer] do + if diffs[pointer - 1][1] == DIFF_DELETE and diffs[pointer][1] == DIFF_INSERT then + local deletion = diffs[pointer - 1][2] + local insertion = diffs[pointer][2] + local overlap_length1 = _diff_commonOverlap(deletion, insertion) + local overlap_length2 = _diff_commonOverlap(insertion, deletion) + if overlap_length1 >= overlap_length2 then + if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then + -- Overlap found. Insert an equality and trim the surrounding edits. + tinsert(diffs, pointer, { DIFF_EQUAL, strsub(insertion, 1, overlap_length1) }) + diffs[pointer - 1][2] = strsub(deletion, 1, #deletion - overlap_length1) + diffs[pointer + 1][2] = strsub(insertion, overlap_length1 + 1) + pointer = pointer + 1 + end + else + if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then + -- Reverse overlap found. + -- Insert an equality and swap and trim the surrounding edits. + tinsert(diffs, pointer, { DIFF_EQUAL, strsub(deletion, 1, overlap_length2) }) + diffs[pointer - 1] = { DIFF_INSERT, strsub(insertion, 1, #insertion - overlap_length2) } + diffs[pointer + 1] = { DIFF_DELETE, strsub(deletion, overlap_length2 + 1) } + pointer = pointer + 1 + end + end + pointer = pointer + 1 + end + pointer = pointer + 1 + end +end + +--[[ +* Reduce the number of edits by eliminating operationally trivial equalities. +* @param {Array.>} diffs Array of diff tuples. +--]] +function diff_cleanupEfficiency(diffs) + local changes = false + -- Stack of indices where equalities are found. + local equalities = {} + -- Keeping our own length var is faster. + local equalitiesLength = 0 + -- Always equal to diffs[equalities[equalitiesLength]][2] + local lastEquality = nil + -- Index of current position. + local pointer = 1 + + -- The following four are really booleans but are stored as numbers because + -- they are used at one point like this: + -- + -- (pre_ins + pre_del + post_ins + post_del) == 3 + -- + -- ...i.e. checking that 3 of them are true and 1 of them is false. + + -- Is there an insertion operation before the last equality. + local pre_ins = 0 + -- Is there a deletion operation before the last equality. + local pre_del = 0 + -- Is there an insertion operation after the last equality. + local post_ins = 0 + -- Is there a deletion operation after the last equality. + local post_del = 0 + + while diffs[pointer] do + if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. + local diffText = diffs[pointer][2] + if (#diffText < Diff_EditCost) and (post_ins == 1 or post_del == 1) then + -- Candidate found. + equalitiesLength = equalitiesLength + 1 + equalities[equalitiesLength] = pointer + pre_ins, pre_del = post_ins, post_del + lastEquality = diffText + else + -- Not a candidate, and can never become one. + equalitiesLength = 0 + lastEquality = nil + end + post_ins, post_del = 0, 0 + else -- An insertion or deletion. + if diffs[pointer][1] == DIFF_DELETE then + post_del = 1 + else + post_ins = 1 + end + --[[ + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + --]] + if + lastEquality + and ( + (pre_ins + pre_del + post_ins + post_del == 4) + or ((#lastEquality < Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del == 3)) + ) + then + -- Duplicate record. + tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) + -- Change second copy to insert. + diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT + -- Throw away the equality we just deleted. + equalitiesLength = equalitiesLength - 1 + lastEquality = nil + if (pre_ins == 1) and (pre_del == 1) then + -- No changes made which could affect previous entry, keep going. + post_ins, post_del = 1, 1 + equalitiesLength = 0 + else + -- Throw away the previous equality. + equalitiesLength = equalitiesLength - 1 + pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 + post_ins, post_del = 0, 0 + end + changes = true + end + end + pointer = pointer + 1 + end + + if changes then + _diff_cleanupMerge(diffs) + end +end + +--[[ +* Compute the Levenshtein distance; the number of inserted, deleted or +* substituted characters. +* @param {Array.>} diffs Array of diff tuples. +* @return {number} Number of changes. +--]] +function diff_levenshtein(diffs) + local levenshtein = 0 + local insertions, deletions = 0, 0 + for x, diff in ipairs(diffs) do + local op, data = diff[1], diff[2] + if op == DIFF_INSERT then + insertions = insertions + #data + elseif op == DIFF_DELETE then + deletions = deletions + #data + elseif op == DIFF_EQUAL then + -- A deletion and an insertion is one substitution. + levenshtein = levenshtein + max(insertions, deletions) + insertions = 0 + deletions = 0 + end + end + levenshtein = levenshtein + max(insertions, deletions) + return levenshtein +end + +--[[ +* Convert a diff array into a pretty HTML report. +* @param {Array.>} diffs Array of diff tuples. +* @return {string} HTML representation. +--]] +function diff_prettyHtml(diffs) + local html = {} + for x, diff in ipairs(diffs) do + local op = diff[1] -- Operation (insert, delete, equal) + local data = diff[2] -- Text of change. + local text = gsub(data, htmlEncode_pattern, htmlEncode_replace) + if op == DIFF_INSERT then + html[x] = '' .. text .. "" + elseif op == DIFF_DELETE then + html[x] = '' .. text .. "" + elseif op == DIFF_EQUAL then + html[x] = "" .. text .. "" + end + end + return tconcat(html) +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE DIFF FUNCTIONS +-- --------------------------------------------------------------------------- + +--[[ +* Find the differences between two texts. Assumes that the texts do not +* have any common prefix or suffix. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {boolean} checklines Has no effect in Lua. +* @param {number} deadline Time when the diff should be complete by. +* @return {Array.>} Array of diff tuples. +* @private +--]] +function _diff_compute(text1, text2, checklines, deadline) + if #text1 == 0 then + -- Just add some text (speedup). + return { { DIFF_INSERT, text2 } } + end + + if #text2 == 0 then + -- Just delete some text (speedup). + return { { DIFF_DELETE, text1 } } + end + + local diffs + + local longtext = (#text1 > #text2) and text1 or text2 + local shorttext = (#text1 > #text2) and text2 or text1 + local i = indexOf(longtext, shorttext) + + if i ~= nil then + -- Shorter text is inside the longer text (speedup). + diffs = { + { DIFF_INSERT, strsub(longtext, 1, i - 1) }, + { DIFF_EQUAL, shorttext }, + { DIFF_INSERT, strsub(longtext, i + #shorttext) }, + } + -- Swap insertions for deletions if diff is reversed. + if #text1 > #text2 then + diffs[1][1], diffs[3][1] = DIFF_DELETE, DIFF_DELETE + end + return diffs + end + + if #shorttext == 1 then + -- Single character string. + -- After the previous speedup, the character can't be an equality. + return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } + end + + -- Check to see if the problem can be split in two. + do + local text1_a, text1_b, text2_a, text2_b, mid_common = _diff_halfMatch(text1, text2) + + if text1_a then + -- A half-match was found, sort out the return data. + -- Send both pairs off for separate processing. + local diffs_a = diff_main(text1_a, text2_a, checklines, deadline) + local diffs_b = diff_main(text1_b, text2_b, checklines, deadline) + -- Merge the results. + local diffs_a_len = #diffs_a + diffs = diffs_a + diffs[diffs_a_len + 1] = { DIFF_EQUAL, mid_common } + for i, b_diff in ipairs(diffs_b) do + diffs[diffs_a_len + 1 + i] = b_diff + end + return diffs + end + end + + return _diff_bisect(text1, text2, deadline) +end + +--[[ +* Find the 'middle snake' of a diff, split the problem in two +* and return the recursively constructed diff. +* See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {number} deadline Time at which to bail if not yet complete. +* @return {Array.>} Array of diff tuples. +* @private +--]] +function _diff_bisect(text1, text2, deadline) + -- Cache the text lengths to prevent multiple calls. + local text1_length = #text1 + local text2_length = #text2 + local _sub, _element + local max_d = ceil((text1_length + text2_length) / 2) + local v_offset = max_d + local v_length = 2 * max_d + local v1 = {} + local v2 = {} + -- Setting all elements to -1 is faster in Lua than mixing integers and nil. + for x = 0, v_length - 1 do + v1[x] = -1 + v2[x] = -1 + end + v1[v_offset + 1] = 0 + v2[v_offset + 1] = 0 + local delta = text1_length - text2_length + -- If the total number of characters is odd, then + -- the front path will collide with the reverse path. + local front = (delta % 2 ~= 0) + -- Offsets for start and end of k loop. + -- Prevents mapping of space beyond the grid. + local k1start = 0 + local k1end = 0 + local k2start = 0 + local k2end = 0 + for d = 0, max_d - 1 do + -- Bail out if deadline is reached. + if clock() > deadline then + break + end + + -- Walk the front path one step. + for k1 = -d + k1start, d - k1end, 2 do + local k1_offset = v_offset + k1 + local x1 + if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then + x1 = v1[k1_offset + 1] + else + x1 = v1[k1_offset - 1] + 1 + end + local y1 = x1 - k1 + while (x1 <= text1_length) and (y1 <= text2_length) and (strsub(text1, x1, x1) == strsub(text2, y1, y1)) do + x1 = x1 + 1 + y1 = y1 + 1 + end + v1[k1_offset] = x1 + if x1 > text1_length + 1 then + -- Ran off the right of the graph. + k1end = k1end + 2 + elseif y1 > text2_length + 1 then + -- Ran off the bottom of the graph. + k1start = k1start + 2 + elseif front then + local k2_offset = v_offset + delta - k1 + if k2_offset >= 0 and k2_offset < v_length and v2[k2_offset] ~= -1 then + -- Mirror x2 onto top-left coordinate system. + local x2 = text1_length - v2[k2_offset] + 1 + if x1 > x2 then + -- Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline) + end + end + end + end + + -- Walk the reverse path one step. + for k2 = -d + k2start, d - k2end, 2 do + local k2_offset = v_offset + k2 + local x2 + if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then + x2 = v2[k2_offset + 1] + else + x2 = v2[k2_offset - 1] + 1 + end + local y2 = x2 - k2 + while + (x2 <= text1_length) + and (y2 <= text2_length) + and (strsub(text1, -x2, -x2) == strsub(text2, -y2, -y2)) + do + x2 = x2 + 1 + y2 = y2 + 1 + end + v2[k2_offset] = x2 + if x2 > text1_length + 1 then + -- Ran off the left of the graph. + k2end = k2end + 2 + elseif y2 > text2_length + 1 then + -- Ran off the top of the graph. + k2start = k2start + 2 + elseif not front then + local k1_offset = v_offset + delta - k2 + if k1_offset >= 0 and k1_offset < v_length and v1[k1_offset] ~= -1 then + local x1 = v1[k1_offset] + local y1 = v_offset + x1 - k1_offset + -- Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2 + 1 + if x1 > x2 then + -- Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline) + end + end + end + end + end + -- Diff took too long and hit the deadline or + -- number of diffs equals number of characters, no commonality at all. + return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } +end + +--[[ + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} x Index of split point in text1. + * @param {number} y Index of split point in text2. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {Array.>} Array of diff tuples. + * @private +--]] +function _diff_bisectSplit(text1, text2, x, y, deadline) + local text1a = strsub(text1, 1, x - 1) + local text2a = strsub(text2, 1, y - 1) + local text1b = strsub(text1, x) + local text2b = strsub(text2, y) + + -- Compute both diffs serially. + local diffs = diff_main(text1a, text2a, false, deadline) + local diffsb = diff_main(text1b, text2b, false, deadline) + + local diffs_len = #diffs + for i, v in ipairs(diffsb) do + diffs[diffs_len + i] = v + end + return diffs +end + +--[[ +* Determine the common prefix of two strings. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the start of each +* string. +--]] +function _diff_commonPrefix(text1, text2) + -- Quick check for common null cases. + if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, 1) ~= strbyte(text2, 1)) then + return 0 + end + -- Binary search. + -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ + local pointermin = 1 + local pointermax = min(#text1, #text2) + local pointermid = pointermax + local pointerstart = 1 + while pointermin < pointermid do + if strsub(text1, pointerstart, pointermid) == strsub(text2, pointerstart, pointermid) then + pointermin = pointermid + pointerstart = pointermin + else + pointermax = pointermid + end + pointermid = floor(pointermin + (pointermax - pointermin) / 2) + end + return pointermid +end + +--[[ +* Determine the common suffix of two strings. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the end of each string. +--]] +function _diff_commonSuffix(text1, text2) + -- Quick check for common null cases. + if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, -1) ~= strbyte(text2, -1)) then + return 0 + end + -- Binary search. + -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ + local pointermin = 1 + local pointermax = min(#text1, #text2) + local pointermid = pointermax + local pointerend = 1 + while pointermin < pointermid do + if strsub(text1, -pointermid, -pointerend) == strsub(text2, -pointermid, -pointerend) then + pointermin = pointermid + pointerend = pointermin + else + pointermax = pointermid + end + pointermid = floor(pointermin + (pointermax - pointermin) / 2) + end + return pointermid +end + +--[[ +* Determine if the suffix of one string is the prefix of another. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the end of the first +* string and the start of the second string. +* @private +--]] +function _diff_commonOverlap(text1, text2) + -- Cache the text lengths to prevent multiple calls. + local text1_length = #text1 + local text2_length = #text2 + -- Eliminate the null case. + if text1_length == 0 or text2_length == 0 then + return 0 + end + -- Truncate the longer string. + if text1_length > text2_length then + text1 = strsub(text1, text1_length - text2_length + 1) + elseif text1_length < text2_length then + text2 = strsub(text2, 1, text1_length) + end + local text_length = min(text1_length, text2_length) + -- Quick check for the worst case. + if text1 == text2 then + return text_length + end + + -- Start by looking for a single character match + -- and increase length until no match is found. + -- Performance analysis: https://neil.fraser.name/news/2010/11/04/ + local best = 0 + local length = 1 + while true do + local pattern = strsub(text1, text_length - length + 1) + local found = strfind(text2, pattern, 1, true) + if found == nil then + return best + end + length = length + found - 1 + if found == 1 or strsub(text1, text_length - length + 1) == strsub(text2, 1, length) then + best = length + length = length + 1 + end + end +end + +--[[ +* Does a substring of shorttext exist within longtext such that the substring +* is at least half the length of longtext? +* This speedup can produce non-minimal diffs. +* Closure, but does not reference any external variables. +* @param {string} longtext Longer string. +* @param {string} shorttext Shorter string. +* @param {number} i Start index of quarter length substring within longtext. +* @return {?Array.} Five element Array, containing the prefix of +* longtext, the suffix of longtext, the prefix of shorttext, the suffix +* of shorttext and the common middle. Or nil if there was no match. +* @private +--]] +function _diff_halfMatchI(longtext, shorttext, i) + -- Start with a 1/4 length substring at position i as a seed. + local seed = strsub(longtext, i, i + floor(#longtext / 4)) + local j = 0 -- LUANOTE: do not change to 1, was originally -1 + local best_common = "" + local best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b + while true do + j = indexOf(shorttext, seed, j + 1) + if j == nil then + break + end + local prefixLength = _diff_commonPrefix(strsub(longtext, i), strsub(shorttext, j)) + local suffixLength = _diff_commonSuffix(strsub(longtext, 1, i - 1), strsub(shorttext, 1, j - 1)) + if #best_common < suffixLength + prefixLength then + best_common = strsub(shorttext, j - suffixLength, j - 1) .. strsub(shorttext, j, j + prefixLength - 1) + best_longtext_a = strsub(longtext, 1, i - suffixLength - 1) + best_longtext_b = strsub(longtext, i + prefixLength) + best_shorttext_a = strsub(shorttext, 1, j - suffixLength - 1) + best_shorttext_b = strsub(shorttext, j + prefixLength) + end + end + if #best_common * 2 >= #longtext then + return { best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common } + else + return nil + end +end + +--[[ +* Do the two texts share a substring which is at least half the length of the +* longer text? +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {?Array.} Five element Array, containing the prefix of +* text1, the suffix of text1, the prefix of text2, the suffix of +* text2 and the common middle. Or nil if there was no match. +* @private +--]] +function _diff_halfMatch(text1, text2) + if Diff_Timeout <= 0 then + -- Don't risk returning a non-optimal diff if we have unlimited time. + return nil + end + local longtext = (#text1 > #text2) and text1 or text2 + local shorttext = (#text1 > #text2) and text2 or text1 + if (#longtext < 4) or (#shorttext * 2 < #longtext) then + return nil -- Pointless. + end + + -- First check if the second quarter is the seed for a half-match. + local hm1 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 4)) + -- Check again based on the third quarter. + local hm2 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 2)) + local hm + if not hm1 and not hm2 then + return nil + elseif not hm2 then + hm = hm1 + elseif not hm1 then + hm = hm2 + else + -- Both matched. Select the longest. + hm = (#hm1[5] > #hm2[5]) and hm1 or hm2 + end + + -- A half-match was found, sort out the return data. + local text1_a, text1_b, text2_a, text2_b + if #text1 > #text2 then + text1_a, text1_b = hm[1], hm[2] + text2_a, text2_b = hm[3], hm[4] + else + text2_a, text2_b = hm[1], hm[2] + text1_a, text1_b = hm[3], hm[4] + end + local mid_common = hm[5] + return text1_a, text1_b, text2_a, text2_b, mid_common +end + +--[[ +* Given two strings, compute a score representing whether the internal +* boundary falls on logical boundaries. +* Scores range from 6 (best) to 0 (worst). +* @param {string} one First string. +* @param {string} two Second string. +* @return {number} The score. +* @private +--]] +function _diff_cleanupSemanticScore(one, two) + if (#one == 0) or (#two == 0) then + -- Edges are the best. + return 6 + end + + -- Each port of this function behaves slightly differently due to + -- subtle differences in each language's definition of things like + -- 'whitespace'. Since this function's purpose is largely cosmetic, + -- the choice has been made to use each language's native features + -- rather than force total conformity. + local char1 = strsub(one, -1) + local char2 = strsub(two, 1, 1) + local nonAlphaNumeric1 = strmatch(char1, "%W") + local nonAlphaNumeric2 = strmatch(char2, "%W") + local whitespace1 = nonAlphaNumeric1 and strmatch(char1, "%s") + local whitespace2 = nonAlphaNumeric2 and strmatch(char2, "%s") + local lineBreak1 = whitespace1 and strmatch(char1, "%c") + local lineBreak2 = whitespace2 and strmatch(char2, "%c") + local blankLine1 = lineBreak1 and strmatch(one, "\n\r?\n$") + local blankLine2 = lineBreak2 and strmatch(two, "^\r?\n\r?\n") + + if blankLine1 or blankLine2 then + -- Five points for blank lines. + return 5 + elseif lineBreak1 or lineBreak2 then + -- Four points for line breaks. + return 4 + elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then + -- Three points for end of sentences. + return 3 + elseif whitespace1 or whitespace2 then + -- Two points for whitespace. + return 2 + elseif nonAlphaNumeric1 or nonAlphaNumeric2 then + -- One point for non-alphanumeric. + return 1 + end + return 0 +end + +--[[ +* Look for single edits surrounded on both sides by equalities +* which can be shifted sideways to align the edit to a word boundary. +* e.g: The cat came. -> The cat came. +* @param {Array.>} diffs Array of diff tuples. +--]] +function _diff_cleanupSemanticLossless(diffs) + local pointer = 2 + -- Intentionally ignore the first and last element (don't need checking). + while diffs[pointer + 1] do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then + -- This is a single edit surrounded by equalities. + local diff = diffs[pointer] + + local equality1 = prevDiff[2] + local edit = diff[2] + local equality2 = nextDiff[2] + + -- First, shift the edit as far left as possible. + local commonOffset = _diff_commonSuffix(equality1, edit) + if commonOffset > 0 then + local commonString = strsub(edit, -commonOffset) + equality1 = strsub(equality1, 1, -commonOffset - 1) + edit = commonString .. strsub(edit, 1, -commonOffset - 1) + equality2 = commonString .. equality2 + end + + -- Second, step character by character right, looking for the best fit. + local bestEquality1 = equality1 + local bestEdit = edit + local bestEquality2 = equality2 + local bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) + + while strbyte(edit, 1) == strbyte(equality2, 1) do + equality1 = equality1 .. strsub(edit, 1, 1) + edit = strsub(edit, 2) .. strsub(equality2, 1, 1) + equality2 = strsub(equality2, 2) + local score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) + -- The >= encourages trailing rather than leading whitespace on edits. + if score >= bestScore then + bestScore = score + bestEquality1 = equality1 + bestEdit = edit + bestEquality2 = equality2 + end + end + if prevDiff[2] ~= bestEquality1 then + -- We have an improvement, save it back to the diff. + if #bestEquality1 > 0 then + diffs[pointer - 1][2] = bestEquality1 + else + tremove(diffs, pointer - 1) + pointer = pointer - 1 + end + diffs[pointer][2] = bestEdit + if #bestEquality2 > 0 then + diffs[pointer + 1][2] = bestEquality2 + else + tremove(diffs, pointer + 1, 1) + pointer = pointer - 1 + end + end + end + pointer = pointer + 1 + end +end + +--[[ +* Reorder and merge like edit sections. Merge equalities. +* Any edit section can move as long as it doesn't cross an equality. +* @param {Array.>} diffs Array of diff tuples. +--]] +function _diff_cleanupMerge(diffs) + diffs[#diffs + 1] = { DIFF_EQUAL, "" } -- Add a dummy entry at the end. + local pointer = 1 + local count_delete, count_insert = 0, 0 + local text_delete, text_insert = "", "" + local commonlength + while diffs[pointer] do + local diff_type = diffs[pointer][1] + if diff_type == DIFF_INSERT then + count_insert = count_insert + 1 + text_insert = text_insert .. diffs[pointer][2] + pointer = pointer + 1 + elseif diff_type == DIFF_DELETE then + count_delete = count_delete + 1 + text_delete = text_delete .. diffs[pointer][2] + pointer = pointer + 1 + elseif diff_type == DIFF_EQUAL then + -- Upon reaching an equality, check for prior redundancies. + if count_delete + count_insert > 1 then + if (count_delete > 0) and (count_insert > 0) then + -- Factor out any common prefixies. + commonlength = _diff_commonPrefix(text_insert, text_delete) + if commonlength > 0 then + local back_pointer = pointer - count_delete - count_insert + if (back_pointer > 1) and (diffs[back_pointer - 1][1] == DIFF_EQUAL) then + diffs[back_pointer - 1][2] = diffs[back_pointer - 1][2] + .. strsub(text_insert, 1, commonlength) + else + tinsert(diffs, 1, { DIFF_EQUAL, strsub(text_insert, 1, commonlength) }) + pointer = pointer + 1 + end + text_insert = strsub(text_insert, commonlength + 1) + text_delete = strsub(text_delete, commonlength + 1) + end + -- Factor out any common suffixies. + commonlength = _diff_commonSuffix(text_insert, text_delete) + if commonlength ~= 0 then + diffs[pointer][2] = strsub(text_insert, -commonlength) .. diffs[pointer][2] + text_insert = strsub(text_insert, 1, -commonlength - 1) + text_delete = strsub(text_delete, 1, -commonlength - 1) + end + end + -- Delete the offending records and add the merged ones. + pointer = pointer - count_delete - count_insert + for i = 1, count_delete + count_insert do + tremove(diffs, pointer) + end + if #text_delete > 0 then + tinsert(diffs, pointer, { DIFF_DELETE, text_delete }) + pointer = pointer + 1 + end + if #text_insert > 0 then + tinsert(diffs, pointer, { DIFF_INSERT, text_insert }) + pointer = pointer + 1 + end + pointer = pointer + 1 + elseif (pointer > 1) and (diffs[pointer - 1][1] == DIFF_EQUAL) then + -- Merge this equality with the previous one. + diffs[pointer - 1][2] = diffs[pointer - 1][2] .. diffs[pointer][2] + tremove(diffs, pointer) + else + pointer = pointer + 1 + end + count_insert, count_delete = 0, 0 + text_delete, text_insert = "", "" + end + end + if diffs[#diffs][2] == "" then + diffs[#diffs] = nil -- Remove the dummy entry at the end. + end + + -- Second pass: look for single edits surrounded on both sides by equalities + -- which can be shifted sideways to eliminate an equality. + -- e.g: ABAC -> ABAC + local changes = false + pointer = 2 + -- Intentionally ignore the first and last element (don't need checking). + while pointer < #diffs do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then + -- This is a single edit surrounded by equalities. + local diff = diffs[pointer] + local currentText = diff[2] + local prevText = prevDiff[2] + local nextText = nextDiff[2] + if #prevText == 0 then + tremove(diffs, pointer - 1) + changes = true + elseif strsub(currentText, -#prevText) == prevText then + -- Shift the edit over the previous equality. + diff[2] = prevText .. strsub(currentText, 1, -#prevText - 1) + nextDiff[2] = prevText .. nextDiff[2] + tremove(diffs, pointer - 1) + changes = true + elseif strsub(currentText, 1, #nextText) == nextText then + -- Shift the edit over the next equality. + prevDiff[2] = prevText .. nextText + diff[2] = strsub(currentText, #nextText + 1) .. nextText + tremove(diffs, pointer + 1) + changes = true + end + end + pointer = pointer + 1 + end + -- If shifts were made, the diff needs reordering and another shift sweep. + if changes then + -- LUANOTE: no return value, but necessary to use 'return' to get + -- tail calls. + return _diff_cleanupMerge(diffs) + end +end + +--[[ +* loc is a location in text1, compute and return the equivalent location in +* text2. +* e.g. 'The cat' vs 'The big cat', 1->1, 5->8 +* @param {Array.>} diffs Array of diff tuples. +* @param {number} loc Location within text1. +* @return {number} Location within text2. +--]] +function _diff_xIndex(diffs, loc) + local chars1 = 1 + local chars2 = 1 + local last_chars1 = 1 + local last_chars2 = 1 + local x + for _x, diff in ipairs(diffs) do + x = _x + if diff[1] ~= DIFF_INSERT then -- Equality or deletion. + chars1 = chars1 + #diff[2] + end + if diff[1] ~= DIFF_DELETE then -- Equality or insertion. + chars2 = chars2 + #diff[2] + end + if chars1 > loc then -- Overshot the location. + break + end + last_chars1 = chars1 + last_chars2 = chars2 + end + -- Was the location deleted? + if diffs[x + 1] and (diffs[x][1] == DIFF_DELETE) then + return last_chars2 + end + -- Add the remaining character length. + return last_chars2 + (loc - last_chars1) +end + +--[[ +* Compute and return the source text (all equalities and deletions). +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Source text. +--]] +function _diff_text1(diffs) + local text = {} + for x, diff in ipairs(diffs) do + if diff[1] ~= DIFF_INSERT then + text[#text + 1] = diff[2] + end + end + return tconcat(text) +end + +--[[ +* Compute and return the destination text (all equalities and insertions). +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Destination text. +--]] +function _diff_text2(diffs) + local text = {} + for x, diff in ipairs(diffs) do + if diff[1] ~= DIFF_DELETE then + text[#text + 1] = diff[2] + end + end + return tconcat(text) +end + +--[[ +* Crush the diff into an encoded string which describes the operations +* required to transform text1 into text2. +* E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. +* Operations are tab-separated. Inserted text is escaped using %xx notation. +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Delta text. +--]] +function _diff_toDelta(diffs) + local text = {} + for x, diff in ipairs(diffs) do + local op, data = diff[1], diff[2] + if op == DIFF_INSERT then + text[x] = "+" .. gsub(data, percentEncode_pattern, percentEncode_replace) + elseif op == DIFF_DELETE then + text[x] = "-" .. #data + elseif op == DIFF_EQUAL then + text[x] = "=" .. #data + end + end + return tconcat(text, "\t") +end + +--[[ +* Given the original text1, and an encoded string which describes the +* operations required to transform text1 into text2, compute the full diff. +* @param {string} text1 Source string for the diff. +* @param {string} delta Delta text. +* @return {Array.>} Array of diff tuples. +* @throws {Errorend If invalid input. +--]] +function _diff_fromDelta(text1, delta) + local diffs = {} + local diffsLength = 0 -- Keeping our own length var is faster + local pointer = 1 -- Cursor in text1 + for token in gmatch(delta, "[^\t]+") do + -- Each token begins with a one character parameter which specifies the + -- operation of this token (delete, insert, equality). + local tokenchar, param = strsub(token, 1, 1), strsub(token, 2) + if tokenchar == "+" then + local invalidDecode = false + local decoded = gsub(param, "%%(.?.?)", function(c) + local n = tonumber(c, 16) + if (#c ~= 2) or (n == nil) then + invalidDecode = true + return "" + end + return strchar(n) + end) + if invalidDecode then + -- Malformed URI sequence. + error("Illegal escape in _diff_fromDelta: " .. param) + end + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_INSERT, decoded } + elseif (tokenchar == "-") or (tokenchar == "=") then + local n = tonumber(param) + if (n == nil) or (n < 0) then + error("Invalid number in _diff_fromDelta: " .. param) + end + local text = strsub(text1, pointer, pointer + n - 1) + pointer = pointer + n + if tokenchar == "=" then + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_EQUAL, text } + else + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_DELETE, text } + end + else + error("Invalid diff operation in _diff_fromDelta: " .. token) + end + end + if pointer ~= #text1 + 1 then + error("Delta length (" .. (pointer - 1) .. ") does not equal source text length (" .. #text1 .. ").") + end + return diffs +end + +-- --------------------------------------------------------------------------- +-- MATCH API +-- --------------------------------------------------------------------------- + +local _match_bitap, _match_alphabet + +--[[ +* Locate the best instance of 'pattern' in 'text' near 'loc'. +* @param {string} text The text to search. +* @param {string} pattern The pattern to search for. +* @param {number} loc The location to search around. +* @return {number} Best match index or -1. +--]] +function match_main(text, pattern, loc) + -- Check for null inputs. + if text == nil or pattern == nil or loc == nil then + error("Null inputs. (match_main)") + end + + if text == pattern then + -- Shortcut (potentially not guaranteed by the algorithm) + return 1 + elseif #text == 0 then + -- Nothing to match. + return -1 + end + loc = max(1, min(loc, #text)) + if strsub(text, loc, loc + #pattern - 1) == pattern then + -- Perfect match at the perfect spot! (Includes case of null pattern) + return loc + else + -- Do a fuzzy compare. + return _match_bitap(text, pattern, loc) + end +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE MATCH FUNCTIONS +-- --------------------------------------------------------------------------- + +--[[ +* Initialise the alphabet for the Bitap algorithm. +* @param {string} pattern The text to encode. +* @return {Object} Hash of character locations. +* @private +--]] +function _match_alphabet(pattern) + local s = {} + local i = 0 + for c in gmatch(pattern, ".") do + s[c] = bor(s[c] or 0, lshift(1, #pattern - i - 1)) + i = i + 1 + end + return s +end + +--[[ +* Locate the best instance of 'pattern' in 'text' near 'loc' using the +* Bitap algorithm. +* @param {string} text The text to search. +* @param {string} pattern The pattern to search for. +* @param {number} loc The location to search around. +* @return {number} Best match index or -1. +* @private +--]] +function _match_bitap(text, pattern, loc) + if #pattern > Match_MaxBits then + error("Pattern too long.") + end + + -- Initialise the alphabet. + local s = _match_alphabet(pattern) + + --[[ + * Compute and return the score for a match with e errors and x location. + * Accesses loc and pattern through being a closure. + * @param {number} e Number of errors in match. + * @param {number} x Location of match. + * @return {number} Overall score for match (0.0 = good, 1.0 = bad). + * @private + --]] + local function _match_bitapScore(e, x) + local accuracy = e / #pattern + local proximity = abs(loc - x) + if Match_Distance == 0 then + -- Dodge divide by zero error. + return (proximity == 0) and 1 or accuracy + end + return accuracy + (proximity / Match_Distance) + end + + -- Highest score beyond which we give up. + local score_threshold = Match_Threshold + -- Is there a nearby exact match? (speedup) + local best_loc = indexOf(text, pattern, loc) + if best_loc then + score_threshold = min(_match_bitapScore(0, best_loc), score_threshold) + -- LUANOTE: Ideally we'd also check from the other direction, but Lua + -- doesn't have an efficent lastIndexOf function. + end + + -- Initialise the bit arrays. + local matchmask = lshift(1, #pattern - 1) + best_loc = -1 + + local bin_min, bin_mid + local bin_max = #pattern + #text + local last_rd + for d = 0, #pattern - 1, 1 do + -- Scan for the best match; each iteration allows for one more error. + -- Run a binary search to determine how far from 'loc' we can stray at this + -- error level. + bin_min = 0 + bin_mid = bin_max + while bin_min < bin_mid do + if _match_bitapScore(d, loc + bin_mid) <= score_threshold then + bin_min = bin_mid + else + bin_max = bin_mid + end + bin_mid = floor(bin_min + (bin_max - bin_min) / 2) + end + -- Use the result from this iteration as the maximum for the next. + bin_max = bin_mid + local start = max(1, loc - bin_mid + 1) + local finish = min(loc + bin_mid, #text) + #pattern + + local rd = {} + for j = start, finish do + rd[j] = 0 + end + rd[finish + 1] = lshift(1, d) - 1 + for j = finish, start, -1 do + local charMatch = s[strsub(text, j - 1, j - 1)] or 0 + if d == 0 then -- First pass: exact match. + rd[j] = band(bor((rd[j + 1] * 2), 1), charMatch) + else + -- Subsequent passes: fuzzy match. + -- Functions instead of operators make this hella messy. + rd[j] = bor( + band(bor(lshift(rd[j + 1], 1), 1), charMatch), + bor(bor(lshift(bor(last_rd[j + 1], last_rd[j]), 1), 1), last_rd[j + 1]) + ) + end + if band(rd[j], matchmask) ~= 0 then + local score = _match_bitapScore(d, j - 1) + -- This match will almost certainly be better than any existing match. + -- But check anyway. + if score <= score_threshold then + -- Told you so. + score_threshold = score + best_loc = j - 1 + if best_loc > loc then + -- When passing loc, don't exceed our current distance from loc. + start = max(1, loc * 2 - best_loc) + else + -- Already passed loc, downhill from here on in. + break + end + end + end + end + -- No hope for a (better) match at greater error levels. + if _match_bitapScore(d + 1, loc) > score_threshold then + break + end + last_rd = rd + end + return best_loc +end + +-- ----------------------------------------------------------------------------- +-- PATCH API +-- ----------------------------------------------------------------------------- + +local _patch_addContext, _patch_deepCopy, _patch_addPadding, _patch_splitMax, _patch_appendText, _new_patch_obj + +--[[ +* Compute a list of patches to turn text1 into text2. +* Use diffs if provided, otherwise compute it ourselves. +* There are four ways to call this function, depending on what data is +* available to the caller: +* Method 1: +* a = text1, b = text2 +* Method 2: +* a = diffs +* Method 3 (optimal): +* a = text1, b = diffs +* Method 4 (deprecated, use method 3): +* a = text1, b = text2, c = diffs +* +* @param {string|Array.>} a text1 (methods 1,3,4) or +* Array of diff tuples for text1 to text2 (method 2). +* @param {string|Array.>} opt_b text2 (methods 1,4) or +* Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). +* @param {string|Array.>} opt_c Array of diff tuples for +* text1 to text2 (method 4) or undefined (methods 1,2,3). +* @return {Array.<_new_patch_obj>} Array of patch objects. +--]] +function patch_make(a, opt_b, opt_c) + local text1, diffs + local type_a, type_b, type_c = type(a), type(opt_b), type(opt_c) + if (type_a == "string") and (type_b == "string") and (type_c == "nil") then + -- Method 1: text1, text2 + -- Compute diffs from text1 and text2. + text1 = a + diffs = diff_main(text1, opt_b, true) + if #diffs > 2 then + diff_cleanupSemantic(diffs) + diff_cleanupEfficiency(diffs) + end + elseif (type_a == "table") and (type_b == "nil") and (type_c == "nil") then + -- Method 2: diffs + -- Compute text1 from diffs. + diffs = a + text1 = _diff_text1(diffs) + elseif (type_a == "string") and (type_b == "table") and (type_c == "nil") then + -- Method 3: text1, diffs + text1 = a + diffs = opt_b + elseif (type_a == "string") and (type_b == "string") and (type_c == "table") then + -- Method 4: text1, text2, diffs + -- text2 is not used. + text1 = a + diffs = opt_c + else + error("Unknown call format to patch_make.") + end + + if diffs[1] == nil then + return {} -- Get rid of the null case. + end + + local patches = {} + local patch = _new_patch_obj() + local patchDiffLength = 0 -- Keeping our own length var is faster. + local char_count1 = 0 -- Number of characters into the text1 string. + local char_count2 = 0 -- Number of characters into the text2 string. + -- Start with text1 (prepatch_text) and apply the diffs until we arrive at + -- text2 (postpatch_text). We recreate the patches one by one to determine + -- context info. + local prepatch_text, postpatch_text = text1, text1 + for x, diff in ipairs(diffs) do + local diff_type, diff_text = diff[1], diff[2] + + if (patchDiffLength == 0) and (diff_type ~= DIFF_EQUAL) then + -- A new patch starts here. + patch.start1 = char_count1 + 1 + patch.start2 = char_count2 + 1 + end + + if diff_type == DIFF_INSERT then + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + patch.length2 = patch.length2 + #diff_text + postpatch_text = strsub(postpatch_text, 1, char_count2) + .. diff_text + .. strsub(postpatch_text, char_count2 + 1) + elseif diff_type == DIFF_DELETE then + patch.length1 = patch.length1 + #diff_text + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + postpatch_text = strsub(postpatch_text, 1, char_count2) + .. strsub(postpatch_text, char_count2 + #diff_text + 1) + elseif diff_type == DIFF_EQUAL then + if (#diff_text <= Patch_Margin * 2) and (patchDiffLength ~= 0) and (#diffs ~= x) then + -- Small equality inside a patch. + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + patch.length1 = patch.length1 + #diff_text + patch.length2 = patch.length2 + #diff_text + elseif #diff_text >= Patch_Margin * 2 then + -- Time for a new patch. + if patchDiffLength ~= 0 then + _patch_addContext(patch, prepatch_text) + patches[#patches + 1] = patch + patch = _new_patch_obj() + patchDiffLength = 0 + -- Unlike Unidiff, our patch lists have a rolling context. + -- https://github.com/google/diff-match-patch/wiki/Unidiff + -- Update prepatch text & pos to reflect the application of the + -- just completed patch. + prepatch_text = postpatch_text + char_count1 = char_count2 + end + end + end + + -- Update the current character count. + if diff_type ~= DIFF_INSERT then + char_count1 = char_count1 + #diff_text + end + if diff_type ~= DIFF_DELETE then + char_count2 = char_count2 + #diff_text + end + end + + -- Pick up the leftover patch if not empty. + if patchDiffLength > 0 then + _patch_addContext(patch, prepatch_text) + patches[#patches + 1] = patch + end + + return patches +end + +--[[ +* Merge a set of patches onto the text. Return a patched text, as well +* as a list of true/false values indicating which patches were applied. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @param {string} text Old text. +* @return {Array.>} Two return values, the +* new text and an array of boolean values. +--]] +function patch_apply(patches, text) + if patches[1] == nil then + return text, {} + end + + -- Deep copy the patches so that no changes are made to originals. + patches = _patch_deepCopy(patches) + + local nullPadding = _patch_addPadding(patches) + text = nullPadding .. text .. nullPadding + + _patch_splitMax(patches) + -- delta keeps track of the offset between the expected and actual location + -- of the previous patch. If there are patches expected at positions 10 and + -- 20, but the first patch was found at 12, delta is 2 and the second patch + -- has an effective expected position of 22. + local delta = 0 + local results = {} + for x, patch in ipairs(patches) do + local expected_loc = patch.start2 + delta + local text1 = _diff_text1(patch.diffs) + local start_loc + local end_loc = -1 + if #text1 > Match_MaxBits then + -- _patch_splitMax will only provide an oversized pattern in + -- the case of a monster delete. + start_loc = match_main(text, strsub(text1, 1, Match_MaxBits), expected_loc) + if start_loc ~= -1 then + end_loc = match_main(text, strsub(text1, -Match_MaxBits), expected_loc + #text1 - Match_MaxBits) + if end_loc == -1 or start_loc >= end_loc then + -- Can't find valid trailing context. Drop this patch. + start_loc = -1 + end + end + else + start_loc = match_main(text, text1, expected_loc) + end + if start_loc == -1 then + -- No match found. :( + results[x] = false + -- Subtract the delta for this failed patch from subsequent patches. + delta = delta - patch.length2 - patch.length1 + else + -- Found a match. :) + results[x] = true + delta = start_loc - expected_loc + local text2 + if end_loc == -1 then + text2 = strsub(text, start_loc, start_loc + #text1 - 1) + else + text2 = strsub(text, start_loc, end_loc + Match_MaxBits - 1) + end + if text1 == text2 then + -- Perfect match, just shove the replacement text in. + text = strsub(text, 1, start_loc - 1) .. _diff_text2(patch.diffs) .. strsub(text, start_loc + #text1) + else + -- Imperfect match. Run a diff to get a framework of equivalent + -- indices. + local diffs = diff_main(text1, text2, false) + if (#text1 > Match_MaxBits) and (diff_levenshtein(diffs) / #text1 > Patch_DeleteThreshold) then + -- The end points match, but the content is unacceptably bad. + results[x] = false + else + _diff_cleanupSemanticLossless(diffs) + local index1 = 1 + local index2 + for y, mod in ipairs(patch.diffs) do + if mod[1] ~= DIFF_EQUAL then + index2 = _diff_xIndex(diffs, index1) + end + if mod[1] == DIFF_INSERT then + text = strsub(text, 1, start_loc + index2 - 2) + .. mod[2] + .. strsub(text, start_loc + index2 - 1) + elseif mod[1] == DIFF_DELETE then + text = strsub(text, 1, start_loc + index2 - 2) + .. strsub(text, start_loc + _diff_xIndex(diffs, index1 + #mod[2] - 1)) + end + if mod[1] ~= DIFF_DELETE then + index1 = index1 + #mod[2] + end + end + end + end + end + end + -- Strip the padding off. + text = strsub(text, #nullPadding + 1, -#nullPadding - 1) + return text, results +end + +--[[ +* Take a list of patches and return a textual representation. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {string} Text representation of patches. +--]] +function patch_toText(patches) + local text = {} + for x, patch in ipairs(patches) do + _patch_appendText(patch, text) + end + return tconcat(text) +end + +--[[ +* Parse a textual representation of patches and return a list of patch objects. +* @param {string} textline Text representation of patches. +* @return {Array.<_new_patch_obj>} Array of patch objects. +* @throws {Error} If invalid input. +--]] +function patch_fromText(textline) + local patches = {} + if #textline == 0 then + return patches + end + local text = {} + for line in gmatch(textline, "([^\n]*)") do + text[#text + 1] = line + end + local textPointer = 1 + while textPointer <= #text do + local start1, length1, start2, length2 = strmatch(text[textPointer], "^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@$") + if start1 == nil then + error('Invalid patch string: "' .. text[textPointer] .. '"') + end + local patch = _new_patch_obj() + patches[#patches + 1] = patch + + start1 = tonumber(start1) + length1 = tonumber(length1) or 1 + if length1 == 0 then + start1 = start1 + 1 + end + patch.start1 = start1 + patch.length1 = length1 + + start2 = tonumber(start2) + length2 = tonumber(length2) or 1 + if length2 == 0 then + start2 = start2 + 1 + end + patch.start2 = start2 + patch.length2 = length2 + + textPointer = textPointer + 1 + + while true do + local line = text[textPointer] + if line == nil then + break + end + local sign + sign, line = strsub(line, 1, 1), strsub(line, 2) + + local invalidDecode = false + local decoded = gsub(line, "%%(.?.?)", function(c) + local n = tonumber(c, 16) + if (#c ~= 2) or (n == nil) then + invalidDecode = true + return "" + end + return strchar(n) + end) + if invalidDecode then + -- Malformed URI sequence. + error("Illegal escape in patch_fromText: " .. line) + end + + line = decoded + + if sign == "-" then + -- Deletion. + patch.diffs[#patch.diffs + 1] = { DIFF_DELETE, line } + elseif sign == "+" then + -- Insertion. + patch.diffs[#patch.diffs + 1] = { DIFF_INSERT, line } + elseif sign == " " then + -- Minor equality. + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, line } + elseif sign == "@" then + -- Start of next patch. + break + elseif sign == "" then + -- Blank line? Whatever. + else + -- WTF? + error('Invalid patch mode "' .. sign .. '" in: ' .. line) + end + textPointer = textPointer + 1 + end + end + return patches +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE PATCH FUNCTIONS +-- --------------------------------------------------------------------------- + +local patch_meta = { + __tostring = function(patch) + local buf = {} + _patch_appendText(patch, buf) + return tconcat(buf) + end, +} + +--[[ +* Class representing one patch operation. +* @constructor +--]] +function _new_patch_obj() + return setmetatable({ + --[[ @type {Array.>} ]] + diffs = {}, + --[[ @type {?number} ]] + start1 = 1, -- nil; + --[[ @type {?number} ]] + start2 = 1, -- nil; + --[[ @type {number} ]] + length1 = 0, + --[[ @type {number} ]] + length2 = 0, + }, patch_meta) +end + +--[[ +* Increase the context until it is unique, +* but don't let the pattern expand beyond Match_MaxBits. +* @param {_new_patch_obj} patch The patch to grow. +* @param {string} text Source text. +* @private +--]] +function _patch_addContext(patch, text) + if #text == 0 then + return + end + local pattern = strsub(text, patch.start2, patch.start2 + patch.length1 - 1) + local padding = 0 + + -- LUANOTE: Lua's lack of a lastIndexOf function results in slightly + -- different logic here than in other language ports. + -- Look for the first two matches of pattern in text. If two are found, + -- increase the pattern length. + local firstMatch = indexOf(text, pattern) + local secondMatch = nil + if firstMatch ~= nil then + secondMatch = indexOf(text, pattern, firstMatch + 1) + end + while (#pattern == 0 or secondMatch ~= nil) and (#pattern < Match_MaxBits - Patch_Margin - Patch_Margin) do + padding = padding + Patch_Margin + pattern = strsub(text, max(1, patch.start2 - padding), patch.start2 + patch.length1 - 1 + padding) + firstMatch = indexOf(text, pattern) + if firstMatch ~= nil then + secondMatch = indexOf(text, pattern, firstMatch + 1) + else + secondMatch = nil + end + end + -- Add one chunk for good luck. + padding = padding + Patch_Margin + + -- Add the prefix. + local prefix = strsub(text, max(1, patch.start2 - padding), patch.start2 - 1) + if #prefix > 0 then + tinsert(patch.diffs, 1, { DIFF_EQUAL, prefix }) + end + -- Add the suffix. + local suffix = strsub(text, patch.start2 + patch.length1, patch.start2 + patch.length1 - 1 + padding) + if #suffix > 0 then + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, suffix } + end + + -- Roll back the start points. + patch.start1 = patch.start1 - #prefix + patch.start2 = patch.start2 - #prefix + -- Extend the lengths. + patch.length1 = patch.length1 + #prefix + #suffix + patch.length2 = patch.length2 + #prefix + #suffix +end + +--[[ +* Given an array of patches, return another array that is identical. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {Array.<_new_patch_obj>} Array of patch objects. +--]] +function _patch_deepCopy(patches) + local patchesCopy = {} + for x, patch in ipairs(patches) do + local patchCopy = _new_patch_obj() + local diffsCopy = {} + for i, diff in ipairs(patch.diffs) do + diffsCopy[i] = { diff[1], diff[2] } + end + patchCopy.diffs = diffsCopy + patchCopy.start1 = patch.start1 + patchCopy.start2 = patch.start2 + patchCopy.length1 = patch.length1 + patchCopy.length2 = patch.length2 + patchesCopy[x] = patchCopy + end + return patchesCopy +end + +--[[ +* Add some padding on text start and end so that edges can match something. +* Intended to be called only from within patch_apply. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {string} The padding string added to each side. +--]] +function _patch_addPadding(patches) + local paddingLength = Patch_Margin + local nullPadding = "" + for x = 1, paddingLength do + nullPadding = nullPadding .. strchar(x) + end + + -- Bump all the patches forward. + for x, patch in ipairs(patches) do + patch.start1 = patch.start1 + paddingLength + patch.start2 = patch.start2 + paddingLength + end + + -- Add some padding on start of first diff. + local patch = patches[1] + local diffs = patch.diffs + local firstDiff = diffs[1] + if (firstDiff == nil) or (firstDiff[1] ~= DIFF_EQUAL) then + -- Add nullPadding equality. + tinsert(diffs, 1, { DIFF_EQUAL, nullPadding }) + patch.start1 = patch.start1 - paddingLength -- Should be 0. + patch.start2 = patch.start2 - paddingLength -- Should be 0. + patch.length1 = patch.length1 + paddingLength + patch.length2 = patch.length2 + paddingLength + elseif paddingLength > #firstDiff[2] then + -- Grow first equality. + local extraLength = paddingLength - #firstDiff[2] + firstDiff[2] = strsub(nullPadding, #firstDiff[2] + 1) .. firstDiff[2] + patch.start1 = patch.start1 - extraLength + patch.start2 = patch.start2 - extraLength + patch.length1 = patch.length1 + extraLength + patch.length2 = patch.length2 + extraLength + end + + -- Add some padding on end of last diff. + patch = patches[#patches] + diffs = patch.diffs + local lastDiff = diffs[#diffs] + if (lastDiff == nil) or (lastDiff[1] ~= DIFF_EQUAL) then + -- Add nullPadding equality. + diffs[#diffs + 1] = { DIFF_EQUAL, nullPadding } + patch.length1 = patch.length1 + paddingLength + patch.length2 = patch.length2 + paddingLength + elseif paddingLength > #lastDiff[2] then + -- Grow last equality. + local extraLength = paddingLength - #lastDiff[2] + lastDiff[2] = lastDiff[2] .. strsub(nullPadding, 1, extraLength) + patch.length1 = patch.length1 + extraLength + patch.length2 = patch.length2 + extraLength + end + + return nullPadding +end + +--[[ +* Look through the patches and break up any which are longer than the maximum +* limit of the match algorithm. +* Intended to be called only from within patch_apply. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +--]] +function _patch_splitMax(patches) + local patch_size = Match_MaxBits + local x = 1 + while true do + local patch = patches[x] + if patch == nil then + return + end + if patch.length1 > patch_size then + local bigpatch = patch + -- Remove the big old patch. + tremove(patches, x) + x = x - 1 + local start1 = bigpatch.start1 + local start2 = bigpatch.start2 + local precontext = "" + while bigpatch.diffs[1] do + -- Create one of several smaller patches. + local patch = _new_patch_obj() + local empty = true + patch.start1 = start1 - #precontext + patch.start2 = start2 - #precontext + if precontext ~= "" then + patch.length1, patch.length2 = #precontext, #precontext + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, precontext } + end + while bigpatch.diffs[1] and (patch.length1 < patch_size - Patch_Margin) do + local diff_type = bigpatch.diffs[1][1] + local diff_text = bigpatch.diffs[1][2] + if diff_type == DIFF_INSERT then + -- Insertions are harmless. + patch.length2 = patch.length2 + #diff_text + start2 = start2 + #diff_text + patch.diffs[#patch.diffs + 1] = bigpatch.diffs[1] + tremove(bigpatch.diffs, 1) + empty = false + elseif + (diff_type == DIFF_DELETE) + and (#patch.diffs == 1) + and (patch.diffs[1][1] == DIFF_EQUAL) + and (#diff_text > 2 * patch_size) + then + -- This is a large deletion. Let it pass in one chunk. + patch.length1 = patch.length1 + #diff_text + start1 = start1 + #diff_text + empty = false + patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } + tremove(bigpatch.diffs, 1) + else + -- Deletion or equality. + -- Only take as much as we can stomach. + diff_text = strsub(diff_text, 1, patch_size - patch.length1 - Patch_Margin) + patch.length1 = patch.length1 + #diff_text + start1 = start1 + #diff_text + if diff_type == DIFF_EQUAL then + patch.length2 = patch.length2 + #diff_text + start2 = start2 + #diff_text + else + empty = false + end + patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } + if diff_text == bigpatch.diffs[1][2] then + tremove(bigpatch.diffs, 1) + else + bigpatch.diffs[1][2] = strsub(bigpatch.diffs[1][2], #diff_text + 1) + end + end + end + -- Compute the head context for the next patch. + precontext = _diff_text2(patch.diffs) + precontext = strsub(precontext, -Patch_Margin) + -- Append the end context for this patch. + local postcontext = strsub(_diff_text1(bigpatch.diffs), 1, Patch_Margin) + if postcontext ~= "" then + patch.length1 = patch.length1 + #postcontext + patch.length2 = patch.length2 + #postcontext + if patch.diffs[1] and (patch.diffs[#patch.diffs][1] == DIFF_EQUAL) then + patch.diffs[#patch.diffs][2] = patch.diffs[#patch.diffs][2] .. postcontext + else + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, postcontext } + end + end + if not empty then + x = x + 1 + tinsert(patches, x, patch) + end + end + end + x = x + 1 + end +end + +--[[ +* Emulate GNU diff's format. +* Header: @@ -382,8 +481,9 @@ +* @return {string} The GNU diff string. +--]] +function _patch_appendText(patch, text) + local coords1, coords2 + local length1, length2 = patch.length1, patch.length2 + local start1, start2 = patch.start1, patch.start2 + local diffs = patch.diffs + + if length1 == 1 then + coords1 = start1 + else + coords1 = ((length1 == 0) and (start1 - 1) or start1) .. "," .. length1 + end + + if length2 == 1 then + coords2 = start2 + else + coords2 = ((length2 == 0) and (start2 - 1) or start2) .. "," .. length2 + end + text[#text + 1] = "@@ -" .. coords1 .. " +" .. coords2 .. " @@\n" + + local op + -- Escape the body of the patch with %xx notation. + for x, diff in ipairs(patch.diffs) do + local diff_type = diff[1] + if diff_type == DIFF_INSERT then + op = "+" + elseif diff_type == DIFF_DELETE then + op = "-" + elseif diff_type == DIFF_EQUAL then + op = " " + end + text[#text + 1] = op .. gsub(diffs[x][2], percentEncode_pattern, percentEncode_replace) .. "\n" + end + + return text +end + +-- Expose the API +local _M = {} + +_M.DIFF_DELETE = DIFF_DELETE +_M.DIFF_INSERT = DIFF_INSERT +_M.DIFF_EQUAL = DIFF_EQUAL + +_M.diff_main = diff_main +_M.diff_cleanupSemantic = diff_cleanupSemantic +_M.diff_cleanupEfficiency = diff_cleanupEfficiency +_M.diff_levenshtein = diff_levenshtein +_M.diff_prettyHtml = diff_prettyHtml + +_M.match_main = match_main + +_M.patch_make = patch_make +_M.patch_toText = patch_toText +_M.patch_fromText = patch_fromText +_M.patch_apply = patch_apply + +-- Expose some non-API functions as well, for testing purposes etc. +_M.diff_commonPrefix = _diff_commonPrefix +_M.diff_commonSuffix = _diff_commonSuffix +_M.diff_commonOverlap = _diff_commonOverlap +_M.diff_halfMatch = _diff_halfMatch +_M.diff_bisect = _diff_bisect +_M.diff_cleanupMerge = _diff_cleanupMerge +_M.diff_cleanupSemanticLossless = _diff_cleanupSemanticLossless +_M.diff_text1 = _diff_text1 +_M.diff_text2 = _diff_text2 +_M.diff_toDelta = _diff_toDelta +_M.diff_fromDelta = _diff_fromDelta +_M.diff_xIndex = _diff_xIndex +_M.match_alphabet = _match_alphabet +_M.match_bitap = _match_bitap +_M.new_patch_obj = _new_patch_obj +_M.patch_addContext = _patch_addContext +_M.patch_splitMax = _patch_splitMax +_M.patch_addPadding = _patch_addPadding +_M.settings = settings + +return _M diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua new file mode 100644 index 000000000..b5eb08d35 --- /dev/null +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -0,0 +1,230 @@ +local TextService = game:GetService("TextService") + +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Log = require(Packages.Log) +local Highlighter = require(Packages.Highlighter) +local DMP = require(script:FindFirstChild("DiffMatchPatch")) + +local Theme = require(Plugin.App.Theme) + +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) +local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) + +local e = Roact.createElement + +local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer") + +settings().Studio.Theme:GetColor(Enum.StudioStyleGuideColor.MainButton, Enum.StudioStyleGuideModifier.Disabled) + +function StringDiffVisualizer:init() + self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) + self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + + self.oldHighlights = Roact.createRef() + self.newHighlights = Roact.createRef() + + self.themeConnection = nil + self:updateHighlighterTheme() + + self:calculateContentSize() +end + +function StringDiffVisualizer:didMount() + self.themeConnection = settings():GetService("Studio").ThemeChanged:Connect(function() + self:updateHighlighterTheme() + end) +end + +function StringDiffVisualizer:willUnmount() + self.themeConnection:Disconnect() +end + +function StringDiffVisualizer:updateHighlighterTheme() + local studioTheme = settings():GetService("Studio").Theme + self.setScriptBackground(studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBackground)) + Highlighter.setTokenColors({ + ["iden"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptText), + ["keyword"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptKeyword), + ["builtin"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBuiltInFunction), + ["string"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptString), + ["number"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptNumber), + ["comment"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptComment), + ["operator"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptOperator), + }) +end + +function StringDiffVisualizer:calculateContentSize() + local oldText, newText = self.props.oldText, self.props.newText + local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) + local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) + + self.setContentSize( + Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y)) + ) +end + +function StringDiffVisualizer:calculateDiffLines() + local oldText, newText = self.props.oldText, self.props.newText + + -- Diff the two texts + local diffs = DMP.diff_main(oldText, newText) + DMP.diff_cleanupSemantic(diffs) + + -- Determine which lines to highlight + local add, remove = {}, {} + + local oldLineNum, newLineNum = 1, 1 + for _, diff in diffs do + local action, text = diff[1], diff[2] + local lines = select(2, string.gsub(text, "\n", "\n")) + + if action == DMP.DIFF_EQUAL then + oldLineNum += lines + newLineNum += lines + elseif action == DMP.DIFF_INSERT then + for i = newLineNum, newLineNum + lines do + add[i] = true + end + newLineNum += lines + elseif action == DMP.DIFF_DELETE then + for i = oldLineNum, oldLineNum + lines do + remove[i] = true + end + oldLineNum += lines + else + Log.warn("Unknown diff action: {} {}", action, text) + end + end + + if self.oldHighlights.current then + for _, lineLabel in self.oldHighlights.current:GetChildren() do + lineLabel.BackgroundTransparency = + if remove[tonumber(string.match(lineLabel.Name, "%d+"))] then 0.3 else 1 + end + end + + if self.newHighlights.current then + for _, lineLabel in self.newHighlights.current:GetChildren() do + lineLabel.BackgroundTransparency = + if add[tonumber(string.match(lineLabel.Name, "%d+"))] then 0.3 else 1 + end + end + + return add, remove +end + +function StringDiffVisualizer:willUpdate() + self:calculateContentSize() +end + +function StringDiffVisualizer:render() + local oldText, newText = self.props.oldText, self.props.newText + + return Theme.with(function(theme) + if self.newHighlights.current then + for _, lineLabel in self.newHighlights.current:GetChildren() do + lineLabel.BackgroundColor3 = theme.Diff.Add + lineLabel.BorderSizePixel = 0 + end + end + if self.oldHighlights.current then + for _, lineLabel in self.oldHighlights.current:GetChildren() do + lineLabel.BackgroundColor3 = theme.Diff.Remove + lineLabel.BorderSizePixel = 0 + end + end + + self:calculateDiffLines() + + return e(BorderedContainer, { + size = self.props.size, + position = self.props.position, + anchorPoint = self.props.anchorPoint, + transparency = self.props.transparency, + }, { + Background = e("Frame", { + Size = UDim2.new(1, 0, 1, 0), + Position = UDim2.new(0, 0, 0, 0), + BorderSizePixel = 0, + BackgroundColor3 = self.scriptBackground, + ZIndex = -10, + }, { + UICorner = e("UICorner", { + CornerRadius = UDim.new(0, 5), + }) + }), + Separator = e("Frame", { + Size = UDim2.new(0, 2, 1, 0), + Position = UDim2.new(0.5, 0, 0, 0), + AnchorPoint = Vector2.new(0.5, 0), + BorderSizePixel = 0, + BackgroundColor3 = theme.BorderedContainer.BorderColor, + BackgroundTransparency = 0.5, + }), + Old = e(ScrollingFrame, { + position = UDim2.new(0, 2, 0, 2), + size = UDim2.new(0.5, -7, 1, -4), + scrollingDirection = Enum.ScrollingDirection.XY, + transparency = self.props.transparency, + contentSize = self.contentSize, + }, { + TextLabel = e("TextLabel", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + Text = oldText, + Font = Enum.Font.RobotoMono, + TextSize = 16, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Top, + TextColor3 = Color3.fromRGB(255, 255, 255), + [Roact.Event.AncestryChanged] = function(rbx) + if rbx:IsDescendantOf(game) then + Highlighter.highlight({ + textObject = rbx, + }) + end + end, + }, { + SyntaxHighlights = e("Folder", { + [Roact.Ref] = self.oldHighlights, + }), + }), + }), + New = e(ScrollingFrame, { + position = UDim2.new(0.5, 5, 0, 2), + size = UDim2.new(0.5, -7, 1, -4), + scrollingDirection = Enum.ScrollingDirection.XY, + transparency = self.props.transparency, + contentSize = self.contentSize, + }, { + TextLabel = e("TextLabel", { + Size = UDim2.new(1, 0, 1, 0), + BackgroundTransparency = 1, + Text = newText, + Font = Enum.Font.RobotoMono, + TextSize = 16, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Top, + TextColor3 = Color3.fromRGB(255, 255, 255), + [Roact.Event.AncestryChanged] = function(rbx) + if rbx:IsDescendantOf(game) then + Highlighter.highlight({ + textObject = rbx, + }) + end + end, + }, { + SyntaxHighlights = e("Folder", { + [Roact.Ref] = self.newHighlights, + }), + }), + }), + }) + end) +end + +return StringDiffVisualizer From 66af4264e1adc6caf29e3b37f2e74a57948e5e9b Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 2 Jul 2023 12:31:01 -0700 Subject: [PATCH 05/30] Add trace logs --- plugin/src/App/Components/StringDiffVisualizer/init.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index b5eb08d35..a0fdbc592 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -71,8 +71,12 @@ function StringDiffVisualizer:calculateDiffLines() local oldText, newText = self.props.oldText, self.props.newText -- Diff the two texts + local startClock = os.clock() local diffs = DMP.diff_main(oldText, newText) DMP.diff_cleanupSemantic(diffs) + local stopClock = os.clock() + + Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diffs", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) -- Determine which lines to highlight local add, remove = {}, {} From f592c88c56773ccde8aa399b732c4879f7cd9a89 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 2 Jul 2023 12:31:49 -0700 Subject: [PATCH 06/30] Add special case for showing Source changes --- .../Components/PatchVisualizer/ChangeList.lua | 85 +++++++++++++++++++ plugin/src/Assets.lua | 1 + 2 files changed, 86 insertions(+) diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index beb0d3270..26c208e51 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -4,8 +4,10 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) +local Assets = require(Plugin.Assets) local Theme = require(Plugin.App.Theme) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) +local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local DisplayValue = require(script.Parent.DisplayValue) local e = Roact.createElement @@ -93,6 +95,89 @@ function ChangeList:render() continue -- Skip headers, already handled above end + -- Special case for .Source updates + -- because we want to display a syntax highlighted diff + -- for better UX + if tostring(values[1]) == "Source" and (columnVisibility[2] and columnVisibility[3]) then + rows[row] = e("Frame", { + Size = UDim2.new(1, 0, 0, 30), + BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, + BackgroundColor3 = theme.Diff.Row, + BorderSizePixel = 0, + LayoutOrder = row, + }, { + Padding = e("UIPadding", pad), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Left, + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + A = e("TextLabel", { + Visible = columnVisibility[1], + Text = tostring(values[1]), + BackgroundTransparency = 1, + Font = Enum.Font.GothamMedium, + TextSize = 14, + TextColor3 = theme.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = props.transparency, + TextTruncate = Enum.TextTruncate.AtEnd, + Size = UDim2.new(0.3, 0, 1, 0), + LayoutOrder = 1, + }), + Button = e("TextButton", { + Text = "", + Size = UDim2.new(0.7, 0, 1, -4), + LayoutOrder = 2, + BackgroundTransparency = 1, + [Roact.Event.Activated] = function() + if props.showSourceDiff then + props.showSourceDiff(tostring(values[2]), tostring(values[3])) + end + end, + }, { + e(BorderedContainer, { + size = UDim2.new(1, 0, 1, 0), + transparency = self.props.transparency, + }, { + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + HorizontalAlignment = Enum.HorizontalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Center, + Padding = UDim.new(0, 5), + }), + Label = e("TextLabel", { + Text = "View Diff", + BackgroundTransparency = 1, + Font = Enum.Font.GothamMedium, + TextSize = 14, + TextColor3 = theme.Settings.Setting.DescriptionColor, + TextXAlignment = Enum.TextXAlignment.Left, + TextTransparency = props.transparency, + TextTruncate = Enum.TextTruncate.AtEnd, + Size = UDim2.new(0, 65, 1, 0), + LayoutOrder = 1, + }), + Icon = e("ImageLabel", { + Image = Assets.Images.Icons.Expand, + ImageColor3 = theme.Checkbox.Active.IconColor, + ImageTransparency = self.props.transparency, + + Size = UDim2.new(0, 16, 0, 16), + Position = UDim2.new(0.5, 0, 0.5, 0), + AnchorPoint = Vector2.new(0.5, 0.5), + + BackgroundTransparency = 1, + LayoutOrder = 2, + }), + }), + }), + }) + continue + end + rows[row] = e("Frame", { Size = UDim2.new(1, 0, 0, 30), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index 3c919b54b..9c3c11efe 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -24,6 +24,7 @@ local Assets = { Close = "rbxassetid://6012985953", Back = "rbxassetid://6017213752", Reset = "rbxassetid://10142422327", + Expand = "rbxassetid://12045401097", }, Diff = { Add = "rbxassetid://10434145835", From 49232c67b2cf4a1f6f2d2eac7c056ff5672e2be6 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 2 Jul 2023 12:57:38 -0700 Subject: [PATCH 07/30] Implement diff view in confirming --- .../Components/PatchVisualizer/ChangeList.lua | 5 +- .../Components/PatchVisualizer/DomLabel.lua | 2 + .../App/Components/PatchVisualizer/init.lua | 1 + plugin/src/App/StatusPages/Confirming.lua | 53 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index 26c208e51..738cc91a9 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -96,9 +96,8 @@ function ChangeList:render() end -- Special case for .Source updates - -- because we want to display a syntax highlighted diff - -- for better UX - if tostring(values[1]) == "Source" and (columnVisibility[2] and columnVisibility[3]) then + -- because we want to display a syntax highlighted diff for better UX + if self.props.showSourceDiff and tostring(values[1]) == "Source" and (columnVisibility[2] and columnVisibility[3]) then rows[row] = e("Frame", { Size = UDim2.new(1, 0, 0, 30), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 9f967c32f..ce83ff304 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -33,6 +33,7 @@ function Expansion:render() changes = props.changeList, transparency = props.transparency, columnVisibility = props.columnVisibility, + showSourceDiff = props.showSourceDiff, }), }) end @@ -128,6 +129,7 @@ function DomLabel:render() transparency = props.transparency, changeList = props.changeList, columnVisibility = props.columnVisibility, + showSourceDiff = props.showSourceDiff, }) else nil, DiffIcon = if props.patchType diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index 95673dfe7..1a9caf986 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -367,6 +367,7 @@ function PatchVisualizer:render() changeList = node.changeList, depth = depth, transparency = self.props.transparency, + showSourceDiff = self.props.showSourceDiff, }) ) diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 3359eaf61..22e481f48 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -13,6 +13,7 @@ local Header = require(Plugin.App.Components.Header) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) +local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local e = Roact.createElement @@ -21,6 +22,12 @@ local ConfirmingPage = Roact.Component:extend("ConfirmingPage") function ConfirmingPage:init() self.contentSize, self.setContentSize = Roact.createBinding(0) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) + + self:setState({ + showingSourceDiff = false, + oldSource = "", + newSource = "", + }) end function ConfirmingPage:render() @@ -55,6 +62,14 @@ function ConfirmingPage:render() columnVisibility = {true, true, true}, patch = self.props.confirmData.patch, instanceMap = self.props.confirmData.instanceMap, + + showSourceDiff = function(oldSource: string, newSource: string) + self:setState({ + showingSourceDiff = true, + oldSource = oldSource, + newSource = newSource, + }) + end, }), Buttons = e("Frame", { @@ -120,6 +135,44 @@ function ConfirmingPage:render() PaddingLeft = UDim.new(0, 20), PaddingRight = UDim.new(0, 20), }), + + SourceDiff = e(StudioPluginGui, { + id = "Rojo_SourceDiff", + title = "Source diff", + active = self.state.showingSourceDiff, + + initDockState = Enum.InitialDockState.Float, + initEnabled = true, + overridePreviousState = true, + floatingSize = Vector2.new(500, 350), + minimumSize = Vector2.new(400, 250), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + self:setState({ + showingSourceDiff = false, + oldSource = "", + newSource = "", + }) + end, + }, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + e(StringDiffVisualizer, { + size = UDim2.new(1, -10, 1, -10), + position = UDim2.new(0, 5, 0, 5), + anchorPoint = Vector2.new(0, 0), + transparency = self.props.transparency, + + oldText = self.state.oldSource, + newText = self.state.newSource, + }) + }), + }), }) if self.props.createPopup then From 6938fe3319282bfef5fb539368d2b936c66d7ae5 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 2 Jul 2023 13:20:42 -0700 Subject: [PATCH 08/30] Improve diff readability by ignoring whitespace changes --- .../Components/StringDiffVisualizer/init.lua | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index a0fdbc592..806bff31a 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -73,7 +73,7 @@ function StringDiffVisualizer:calculateDiffLines() -- Diff the two texts local startClock = os.clock() local diffs = DMP.diff_main(oldText, newText) - DMP.diff_cleanupSemantic(diffs) + DMP.diff_cleanupEfficiency(diffs) local stopClock = os.clock() Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diffs", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) @@ -90,13 +90,31 @@ function StringDiffVisualizer:calculateDiffLines() oldLineNum += lines newLineNum += lines elseif action == DMP.DIFF_INSERT then - for i = newLineNum, newLineNum + lines do - add[i] = true + if lines > 0 then + local textLines = string.split(text, "\n") + for i, textLine in textLines do + if string.match(textLine, "%S") then + add[newLineNum + i - 1] = true + end + end + else + if string.match(text, "%S") then + add[newLineNum] = true + end end newLineNum += lines elseif action == DMP.DIFF_DELETE then - for i = oldLineNum, oldLineNum + lines do - remove[i] = true + if lines > 0 then + local textLines = string.split(text, "\n") + for i, textLine in textLines do + if string.match(textLine, "%S") then + remove[oldLineNum + i - 1] = true + end + end + else + if string.match(text, "%S") then + remove[oldLineNum] = true + end end oldLineNum += lines else From 4554141347e3088184d4a63bcf4a229c95b80bd7 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 2 Jul 2023 14:20:11 -0700 Subject: [PATCH 09/30] Move code labels to reusable component and reduce wasted diff computes --- plugin/src/App/Components/CodeLabel.lua | 88 ++++++++++++ .../Components/StringDiffVisualizer/init.lua | 136 +++++------------- plugin/src/App/StatusPages/Confirming.lua | 2 - 3 files changed, 122 insertions(+), 104 deletions(-) create mode 100644 plugin/src/App/Components/CodeLabel.lua diff --git a/plugin/src/App/Components/CodeLabel.lua b/plugin/src/App/Components/CodeLabel.lua new file mode 100644 index 000000000..3f8799f1e --- /dev/null +++ b/plugin/src/App/Components/CodeLabel.lua @@ -0,0 +1,88 @@ +local Rojo = script:FindFirstAncestor("Rojo") +local Plugin = Rojo.Plugin +local Packages = Rojo.Packages + +local Roact = require(Packages.Roact) +local Highlighter = require(Packages.Highlighter) + +local e = Roact.createElement + +local CodeLabel = Roact.PureComponent:extend("CodeLabel") + +function CodeLabel:init() + self.labelRef = Roact.createRef() + self.highlightsRef = Roact.createRef() + + self.themeConnection = nil +end + +function CodeLabel:didMount() + self:updateHighlighterTheme() + self.themeConnection = settings():GetService("Studio").ThemeChanged:Connect(function() + self:updateHighlighterTheme() + end) + + Highlighter.highlight({ + textObject = self.labelRef:getValue(), + }) + self:updateHighlights() +end + +function CodeLabel:willUnmount() + if self.themeConnection then + self.themeConnection:Disconnect() + self.themeConnection = nil + end +end + +function CodeLabel:didUpdate() + self:updateHighlights() +end + +function CodeLabel:updateHighlighterTheme() + local studioTheme = settings():GetService("Studio").Theme + Highlighter.setTokenColors({ + ["iden"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptText), + ["keyword"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptKeyword), + ["builtin"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBuiltInFunction), + ["string"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptString), + ["number"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptNumber), + ["comment"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptComment), + ["operator"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptOperator), + }) +end + +function CodeLabel:updateHighlights() + local highlights = self.highlightsRef:getValue() + if not highlights then + return + end + + for _, lineLabel in highlights:GetChildren() do + local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0") + lineLabel.BackgroundColor3 = self.props.lineBackground + lineLabel.BorderSizePixel = 0 + lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1 + end +end + +function CodeLabel:render() + return e("TextLabel", { + Size = self.props.size, + Position = self.props.position, + Text = self.props.text, + BackgroundTransparency = 1, + Font = Enum.Font.RobotoMono, + TextSize = 16, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Top, + TextColor3 = Color3.fromRGB(255, 255, 255), + [Roact.Ref] = self.labelRef, + }, { + SyntaxHighlights = e("Folder", { + [Roact.Ref] = self.highlightsRef, + }), + }) +end + +return CodeLabel diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 806bff31a..604f6bba4 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -6,11 +6,11 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) local Log = require(Packages.Log) -local Highlighter = require(Packages.Highlighter) local DMP = require(script:FindFirstChild("DiffMatchPatch")) local Theme = require(Plugin.App.Theme) +local CodeLabel = require(Plugin.App.Components.CodeLabel) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) @@ -18,47 +18,40 @@ local e = Roact.createElement local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer") -settings().Studio.Theme:GetColor(Enum.StudioStyleGuideColor.MainButton, Enum.StudioStyleGuideModifier.Disabled) - function StringDiffVisualizer:init() self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) - self.oldHighlights = Roact.createRef() - self.newHighlights = Roact.createRef() - - self.themeConnection = nil - self:updateHighlighterTheme() - self:calculateContentSize() -end + self:getScriptBackground() -function StringDiffVisualizer:didMount() - self.themeConnection = settings():GetService("Studio").ThemeChanged:Connect(function() - self:updateHighlighterTheme() - end) -end - -function StringDiffVisualizer:willUnmount() - self.themeConnection:Disconnect() + self:setState({ + add = {}, + remove = {}, + }) end -function StringDiffVisualizer:updateHighlighterTheme() +function StringDiffVisualizer:getScriptBackground() local studioTheme = settings():GetService("Studio").Theme self.setScriptBackground(studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBackground)) - Highlighter.setTokenColors({ - ["iden"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptText), - ["keyword"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptKeyword), - ["builtin"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBuiltInFunction), - ["string"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptString), - ["number"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptNumber), - ["comment"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptComment), - ["operator"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptOperator), - }) +end + +function StringDiffVisualizer:didUpdate(previousProps) + self:getScriptBackground() + + if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then + self:calculateContentSize() + local add, remove = self:calculateDiffLines() + self:setState({ + add = add, + remove = remove, + }) + end end function StringDiffVisualizer:calculateContentSize() local oldText, newText = self.props.oldText, self.props.newText + Log.trace("Calculating content size for {} {}", #oldText, #newText) local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) @@ -76,7 +69,7 @@ function StringDiffVisualizer:calculateDiffLines() DMP.diff_cleanupEfficiency(diffs) local stopClock = os.clock() - Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diffs", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) + Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) -- Determine which lines to highlight local add, remove = {}, {} @@ -122,46 +115,13 @@ function StringDiffVisualizer:calculateDiffLines() end end - if self.oldHighlights.current then - for _, lineLabel in self.oldHighlights.current:GetChildren() do - lineLabel.BackgroundTransparency = - if remove[tonumber(string.match(lineLabel.Name, "%d+"))] then 0.3 else 1 - end - end - - if self.newHighlights.current then - for _, lineLabel in self.newHighlights.current:GetChildren() do - lineLabel.BackgroundTransparency = - if add[tonumber(string.match(lineLabel.Name, "%d+"))] then 0.3 else 1 - end - end - return add, remove end -function StringDiffVisualizer:willUpdate() - self:calculateContentSize() -end - function StringDiffVisualizer:render() local oldText, newText = self.props.oldText, self.props.newText return Theme.with(function(theme) - if self.newHighlights.current then - for _, lineLabel in self.newHighlights.current:GetChildren() do - lineLabel.BackgroundColor3 = theme.Diff.Add - lineLabel.BorderSizePixel = 0 - end - end - if self.oldHighlights.current then - for _, lineLabel in self.oldHighlights.current:GetChildren() do - lineLabel.BackgroundColor3 = theme.Diff.Remove - lineLabel.BorderSizePixel = 0 - end - end - - self:calculateDiffLines() - return e(BorderedContainer, { size = self.props.size, position = self.props.position, @@ -194,26 +154,12 @@ function StringDiffVisualizer:render() transparency = self.props.transparency, contentSize = self.contentSize, }, { - TextLabel = e("TextLabel", { - Size = UDim2.new(1, 0, 1, 0), - BackgroundTransparency = 1, - Text = oldText, - Font = Enum.Font.RobotoMono, - TextSize = 16, - TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Top, - TextColor3 = Color3.fromRGB(255, 255, 255), - [Roact.Event.AncestryChanged] = function(rbx) - if rbx:IsDescendantOf(game) then - Highlighter.highlight({ - textObject = rbx, - }) - end - end, - }, { - SyntaxHighlights = e("Folder", { - [Roact.Ref] = self.oldHighlights, - }), + Source = e(CodeLabel, { + size = UDim2.new(1, 0, 1, 0), + position = UDim2.new(0, 0, 0, 0), + text = oldText, + lineBackground = theme.Diff.Remove, + markedLines = self.state.remove, }), }), New = e(ScrollingFrame, { @@ -223,26 +169,12 @@ function StringDiffVisualizer:render() transparency = self.props.transparency, contentSize = self.contentSize, }, { - TextLabel = e("TextLabel", { - Size = UDim2.new(1, 0, 1, 0), - BackgroundTransparency = 1, - Text = newText, - Font = Enum.Font.RobotoMono, - TextSize = 16, - TextXAlignment = Enum.TextXAlignment.Left, - TextYAlignment = Enum.TextYAlignment.Top, - TextColor3 = Color3.fromRGB(255, 255, 255), - [Roact.Event.AncestryChanged] = function(rbx) - if rbx:IsDescendantOf(game) then - Highlighter.highlight({ - textObject = rbx, - }) - end - end, - }, { - SyntaxHighlights = e("Folder", { - [Roact.Ref] = self.newHighlights, - }), + Source = e(CodeLabel, { + size = UDim2.new(1, 0, 1, 0), + position = UDim2.new(0, 0, 0, 0), + text = newText, + lineBackground = theme.Diff.Add, + markedLines = self.state.add, }), }), }) diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 22e481f48..48a0e567b 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -152,8 +152,6 @@ function ConfirmingPage:render() onClose = function() self:setState({ showingSourceDiff = false, - oldSource = "", - newSource = "", }) end, }, { From 7eb72f313e33c1ac4d5a01eb2c78ddf1fa5bd0d7 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 9 Jul 2023 17:25:04 -0700 Subject: [PATCH 10/30] Include warning support for special cased source and make the button partially match background color --- plugin/src/App/Components/PatchVisualizer/ChangeList.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index 322728047..348391544 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -116,11 +116,11 @@ function ChangeList:render() }), A = e("TextLabel", { Visible = columnVisibility[1], - Text = tostring(values[1]), + Text = (if isWarning then "âš  " else "") .. tostring(values[1]), BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 14, - TextColor3 = theme.Settings.Setting.DescriptionColor, + TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, TextXAlignment = Enum.TextXAlignment.Left, TextTransparency = props.transparency, TextTruncate = Enum.TextTruncate.AtEnd, @@ -140,7 +140,9 @@ function ChangeList:render() }, { e(BorderedContainer, { size = UDim2.new(1, 0, 1, 0), - transparency = self.props.transparency, + transparency = self.props.transparency:map(function(t) + return 0.5 + (0.5 * t) + end), }, { Layout = e("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, From 0ec8170a05ca2cc73c99b12ccc271af1ad5bfe6f Mon Sep 17 00:00:00 2001 From: boatbomber Date: Sun, 9 Jul 2023 17:25:30 -0700 Subject: [PATCH 11/30] Fix tooltips in the diff viewer widget --- plugin/src/App/StatusPages/Confirming.lua | 30 ++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 48a0e567b..9a53b2322 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -155,20 +155,22 @@ function ConfirmingPage:render() }) end, }, { - Tooltips = e(Tooltip.Container, nil), - Content = e("Frame", { - Size = UDim2.fromScale(1, 1), - BackgroundTransparency = 1, - }, { - e(StringDiffVisualizer, { - size = UDim2.new(1, -10, 1, -10), - position = UDim2.new(0, 5, 0, 5), - anchorPoint = Vector2.new(0, 0), - transparency = self.props.transparency, - - oldText = self.state.oldSource, - newText = self.state.newSource, - }) + TooltipsProvider = e(Tooltip.Provider, nil, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + e(StringDiffVisualizer, { + size = UDim2.new(1, -10, 1, -10), + position = UDim2.new(0, 5, 0, 5), + anchorPoint = Vector2.new(0, 0), + transparency = self.props.transparency, + + oldText = self.state.oldSource, + newText = self.state.newSource, + }) + }), }), }), }) From ce6b698ae0af3d2d64953101b2551d15669fc23b Mon Sep 17 00:00:00 2001 From: boatbomber Date: Thu, 13 Jul 2023 23:18:39 -0700 Subject: [PATCH 12/30] Remove old visibility stuff --- plugin/src/App/Components/PatchVisualizer/ChangeList.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index 5a2e825ca..0fa58c9b0 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -97,7 +97,7 @@ function ChangeList:render() -- Special case for .Source updates -- because we want to display a syntax highlighted diff for better UX - if self.props.showSourceDiff and tostring(values[1]) == "Source" and (columnVisibility[2] and columnVisibility[3]) then + if self.props.showSourceDiff and tostring(values[1]) == "Source" then rows[row] = e("Frame", { Size = UDim2.new(1, 0, 0, 30), BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, @@ -113,7 +113,6 @@ function ChangeList:render() VerticalAlignment = Enum.VerticalAlignment.Center, }), A = e("TextLabel", { - Visible = columnVisibility[1], Text = (if isWarning then "âš  " else "") .. tostring(values[1]), BackgroundTransparency = 1, Font = Enum.Font.GothamMedium, From 340c0f7d23e37ac2386366c779b8efea68dcc928 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Mon, 24 Jul 2023 23:10:37 -0700 Subject: [PATCH 13/30] Use latest Highlighter --- plugin/Packages/Highlighter | 2 +- plugin/src/App/Components/CodeLabel.lua | 19 +------------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/plugin/Packages/Highlighter b/plugin/Packages/Highlighter index 2890275c6..09263eacf 160000 --- a/plugin/Packages/Highlighter +++ b/plugin/Packages/Highlighter @@ -1 +1 @@ -Subproject commit 2890275c6bf20d00a21a2fe44f546b666e3ef530 +Subproject commit 09263eacfee6ad5ff3326d12244bc45b6ae8d2f0 diff --git a/plugin/src/App/Components/CodeLabel.lua b/plugin/src/App/Components/CodeLabel.lua index 3f8799f1e..657cbee30 100644 --- a/plugin/src/App/Components/CodeLabel.lua +++ b/plugin/src/App/Components/CodeLabel.lua @@ -4,6 +4,7 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) local Highlighter = require(Packages.Highlighter) +Highlighter.matchStudioSettings() local e = Roact.createElement @@ -17,11 +18,6 @@ function CodeLabel:init() end function CodeLabel:didMount() - self:updateHighlighterTheme() - self.themeConnection = settings():GetService("Studio").ThemeChanged:Connect(function() - self:updateHighlighterTheme() - end) - Highlighter.highlight({ textObject = self.labelRef:getValue(), }) @@ -39,19 +35,6 @@ function CodeLabel:didUpdate() self:updateHighlights() end -function CodeLabel:updateHighlighterTheme() - local studioTheme = settings():GetService("Studio").Theme - Highlighter.setTokenColors({ - ["iden"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptText), - ["keyword"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptKeyword), - ["builtin"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBuiltInFunction), - ["string"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptString), - ["number"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptNumber), - ["comment"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptComment), - ["operator"] = studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptOperator), - }) -end - function CodeLabel:updateHighlights() local highlights = self.highlightsRef:getValue() if not highlights then From 93c102c8035eea71c3c57a64883d2869eb56149f Mon Sep 17 00:00:00 2001 From: boatbomber Date: Mon, 24 Jul 2023 23:14:19 -0700 Subject: [PATCH 14/30] Remove old unused code --- plugin/src/App/Components/CodeLabel.lua | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugin/src/App/Components/CodeLabel.lua b/plugin/src/App/Components/CodeLabel.lua index 657cbee30..c683afa9a 100644 --- a/plugin/src/App/Components/CodeLabel.lua +++ b/plugin/src/App/Components/CodeLabel.lua @@ -13,8 +13,6 @@ local CodeLabel = Roact.PureComponent:extend("CodeLabel") function CodeLabel:init() self.labelRef = Roact.createRef() self.highlightsRef = Roact.createRef() - - self.themeConnection = nil end function CodeLabel:didMount() @@ -24,13 +22,6 @@ function CodeLabel:didMount() self:updateHighlights() end -function CodeLabel:willUnmount() - if self.themeConnection then - self.themeConnection:Disconnect() - self.themeConnection = nil - end -end - function CodeLabel:didUpdate() self:updateHighlights() end From d206c9ac55fbf9bdc5f34bc088249232fca8aa18 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 05:56:56 -0700 Subject: [PATCH 15/30] Use Highlighter to get background color --- plugin/src/App/Components/StringDiffVisualizer/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 604f6bba4..7d04b349d 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -6,6 +6,7 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) local Log = require(Packages.Log) +local Highlighter = require(Packages.Highlighter) local DMP = require(script:FindFirstChild("DiffMatchPatch")) local Theme = require(Plugin.App.Theme) @@ -32,8 +33,7 @@ function StringDiffVisualizer:init() end function StringDiffVisualizer:getScriptBackground() - local studioTheme = settings():GetService("Studio").Theme - self.setScriptBackground(studioTheme:GetColor(Enum.StudioStyleGuideColor.ScriptBackground)) + self.setScriptBackground(Highlighter.getTokenColor("background")) end function StringDiffVisualizer:didUpdate(previousProps) From a93971c79d3cdb80616065f8666341f86ee71f38 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 07:25:02 -0700 Subject: [PATCH 16/30] Don't waste compute on diff cleanup since we aren't really using the diff to apply anything --- plugin/src/App/Components/StringDiffVisualizer/init.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 7d04b349d..06d568076 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -66,7 +66,6 @@ function StringDiffVisualizer:calculateDiffLines() -- Diff the two texts local startClock = os.clock() local diffs = DMP.diff_main(oldText, newText) - DMP.diff_cleanupEfficiency(diffs) local stopClock = os.clock() Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) From c8d3ee78238dac7830b76771cf06c404c57b82fe Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 07:25:43 -0700 Subject: [PATCH 17/30] Add diff view to Connected patch view as well --- plugin/src/App/StatusPages/Confirming.lua | 2 +- plugin/src/App/StatusPages/Connected.lua | 55 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index fe2e001ca..4848bdce1 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -137,7 +137,7 @@ function ConfirmingPage:render() }), SourceDiff = e(StudioPluginGui, { - id = "Rojo_SourceDiff", + id = "Rojo_ConfirmingSourceDiff", title = "Source diff", active = self.state.showingSourceDiff, diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 00f6822bb..54e2b9d14 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -10,12 +10,14 @@ local Theme = require(Plugin.App.Theme) local Assets = require(Plugin.Assets) local PatchSet = require(Plugin.PatchSet) +local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Header = require(Plugin.App.Components.Header) local IconButton = require(Plugin.App.Components.IconButton) local TextButton = require(Plugin.App.Components.TextButton) local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) +local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local e = Roact.createElement @@ -39,7 +41,7 @@ function timeSinceText(elapsed: number): string return ageText end -local ChangesDrawer = Roact.Component:extend("ConnectedPage") +local ChangesDrawer = Roact.Component:extend("ChangesDrawer") function ChangesDrawer:init() -- Hold onto the serve session during the lifecycle of this component @@ -84,6 +86,8 @@ function ChangesDrawer:render() layoutOrder = 3, patchTree = self.props.patchTree, + + showSourceDiff = self.props.showSourceDiff, }), }) end) @@ -226,6 +230,9 @@ function ConnectedPage:init() self:setState({ renderChanges = false, hoveringChangeInfo = false, + showingSourceDiff = false, + oldSource = "", + newSource = "", }) self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") @@ -367,6 +374,14 @@ function ConnectedPage:render() height = self.changeDrawerHeight, layoutOrder = 5, + showSourceDiff = function(oldSource: string, newSource: string) + self:setState({ + showingSourceDiff = true, + oldSource = oldSource, + newSource = newSource, + }) + end, + onClose = function() self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { frequency = 4, @@ -374,6 +389,44 @@ function ConnectedPage:render() })) end, }), + + SourceDiff = e(StudioPluginGui, { + id = "Rojo_ConnectedSourceDiff", + title = "Source diff", + active = self.state.showingSourceDiff, + + initDockState = Enum.InitialDockState.Float, + initEnabled = true, + overridePreviousState = true, + floatingSize = Vector2.new(500, 350), + minimumSize = Vector2.new(400, 250), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + self:setState({ + showingSourceDiff = false, + }) + end, + }, { + TooltipsProvider = e(Tooltip.Provider, nil, { + Tooltips = e(Tooltip.Container, nil), + Content = e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundTransparency = 1, + }, { + e(StringDiffVisualizer, { + size = UDim2.new(1, -10, 1, -10), + position = UDim2.new(0, 5, 0, 5), + anchorPoint = Vector2.new(0, 0), + transparency = self.props.transparency, + + oldText = self.state.oldSource, + newText = self.state.newSource, + }) + }), + }), + }), }) end) end From a1869ee681d09a5c0dcba210091ef93ccdf229a6 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 15:42:29 -0700 Subject: [PATCH 18/30] Fix studio not rendering widgets --- plugin/src/App/Components/Studio/StudioPluginGui.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugin/src/App/Components/Studio/StudioPluginGui.lua b/plugin/src/App/Components/Studio/StudioPluginGui.lua index 59bd3d857..99b0f3877 100644 --- a/plugin/src/App/Components/Studio/StudioPluginGui.lua +++ b/plugin/src/App/Components/Studio/StudioPluginGui.lua @@ -76,6 +76,11 @@ function StudioPluginGui:didUpdate(lastProps) if self.props.active ~= lastProps.active then -- This is intentionally in didUpdate to make sure the initial active state -- (if the PluginGui is open initially) is preserved. + + -- Studio widgets are very unreliable and sometimes need to be flickered + -- in order to force them to render correctly + self.pluginGui.Enabled = self.props.active + self.pluginGui.Enabled = not self.props.active self.pluginGui.Enabled = self.props.active end end From b1cb2adcf3ca3488ef41f50b749cdeba93860d34 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 15:44:58 -0700 Subject: [PATCH 19/30] Add clarifying comment --- plugin/src/App/Components/Studio/StudioPluginGui.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/src/App/Components/Studio/StudioPluginGui.lua b/plugin/src/App/Components/Studio/StudioPluginGui.lua index 99b0f3877..6b9910a91 100644 --- a/plugin/src/App/Components/Studio/StudioPluginGui.lua +++ b/plugin/src/App/Components/Studio/StudioPluginGui.lua @@ -79,6 +79,7 @@ function StudioPluginGui:didUpdate(lastProps) -- Studio widgets are very unreliable and sometimes need to be flickered -- in order to force them to render correctly + -- This happens within a single frame so it doesn't flicker visibly self.pluginGui.Enabled = self.props.active self.pluginGui.Enabled = not self.props.active self.pluginGui.Enabled = self.props.active From 92cc94905e6425054c3b80c426845936bcc95dfd Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 16:58:12 -0700 Subject: [PATCH 20/30] Write a stripped down Luau port of DMP to reduce bloat --- .../StringDiffVisualizer/DiffMatchPatch.lua | 2102 ----------------- .../StringDiffVisualizer/StringDiff.lua | 441 ++++ .../Components/StringDiffVisualizer/init.lua | 14 +- 3 files changed, 448 insertions(+), 2109 deletions(-) delete mode 100644 plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua create mode 100644 plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua diff --git a/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua b/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua deleted file mode 100644 index ba93ba1ee..000000000 --- a/plugin/src/App/Components/StringDiffVisualizer/DiffMatchPatch.lua +++ /dev/null @@ -1,2102 +0,0 @@ ---[[ -* Diff Match and Patch -* Copyright 2018 The diff-match-patch Authors. -* https://github.com/google/diff-match-patch -* -* Based on the JavaScript implementation by Neil Fraser. -* Ported to Lua by Duncan Cross. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. ---]] - ---[[ --- Lua 5.1 and earlier requires the external BitOp library. --- This library is built-in from Lua 5.2 and later as 'bit32'. -require 'bit' -- -local band, bor, lshift - = bit.band, bit.bor, bit.lshift ---]] - -local band, bor, lshift = bit32.band, bit32.bor, bit32.lshift -local type, setmetatable, ipairs, select = type, setmetatable, ipairs, select -local unpack, tonumber, error = unpack, tonumber, error -local strsub, strbyte, strchar, gmatch, gsub = string.sub, string.byte, string.char, string.gmatch, string.gsub -local strmatch, strfind, strformat = string.match, string.find, string.format -local tinsert, tremove, tconcat = table.insert, table.remove, table.concat -local max, min, floor, ceil, abs = math.max, math.min, math.floor, math.ceil, math.abs -local clock = os.clock - --- Utility functions. - -local percentEncode_pattern = "[^A-Za-z0-9%-=;',./~!@#$%&*%(%)_%+ %?]" -local function percentEncode_replace(v) - return strformat("%%%02X", strbyte(v)) -end - -local function indexOf(a, b, start) - if #b == 0 then - return nil - end - return strfind(a, b, start, true) -end - -local htmlEncode_pattern = "[&<>\n]" -local htmlEncode_replace = { - ["&"] = "&", - ["<"] = "<", - [">"] = ">", - ["\n"] = "¶
", -} - --- Public API Functions --- (Exported at the end of the script) - -local diff_main, diff_cleanupSemantic, diff_cleanupEfficiency, diff_levenshtein, diff_prettyHtml - -local match_main - -local patch_make, patch_toText, patch_fromText, patch_apply - ---[[ -* The data structure representing a diff is an array of tuples: -* {{DIFF_DELETE, 'Hello'}, {DIFF_INSERT, 'Goodbye'}, {DIFF_EQUAL, ' world.'}} -* which means: delete 'Hello', add 'Goodbye' and keep ' world.' ---]] -local DIFF_DELETE = -1 -local DIFF_INSERT = 1 -local DIFF_EQUAL = 0 - --- Number of seconds to map a diff before giving up (0 for infinity). -local Diff_Timeout = 1.0 --- Cost of an empty edit operation in terms of edit characters. -local Diff_EditCost = 4 --- At what point is no match declared (0.0 = perfection, 1.0 = very loose). -local Match_Threshold = 0.5 --- How far to search for a match (0 = exact location, 1000+ = broad match). --- A match this many characters away from the expected location will add --- 1.0 to the score (0.0 is a perfect match). -local Match_Distance = 1000 --- When deleting a large block of text (over ~64 characters), how close do --- the contents have to be to match the expected contents. (0.0 = perfection, --- 1.0 = very loose). Note that Match_Threshold controls how closely the --- end points of a delete need to match. -local Patch_DeleteThreshold = 0.5 --- Chunk size for context length. -local Patch_Margin = 4 --- The number of bits in an int. -local Match_MaxBits = 32 - -function settings(new) - if new then - Diff_Timeout = new.Diff_Timeout or Diff_Timeout - Diff_EditCost = new.Diff_EditCost or Diff_EditCost - Match_Threshold = new.Match_Threshold or Match_Threshold - Match_Distance = new.Match_Distance or Match_Distance - Patch_DeleteThreshold = new.Patch_DeleteThreshold or Patch_DeleteThreshold - Patch_Margin = new.Patch_Margin or Patch_Margin - Match_MaxBits = new.Match_MaxBits or Match_MaxBits - else - return { - Diff_Timeout = Diff_Timeout, - Diff_EditCost = Diff_EditCost, - Match_Threshold = Match_Threshold, - Match_Distance = Match_Distance, - Patch_DeleteThreshold = Patch_DeleteThreshold, - Patch_Margin = Patch_Margin, - Match_MaxBits = Match_MaxBits, - } - end -end - --- --------------------------------------------------------------------------- --- DIFF API --- --------------------------------------------------------------------------- - --- The private diff functions -local _diff_compute, _diff_bisect, _diff_halfMatchI, _diff_halfMatch, _diff_cleanupSemanticScore, _diff_cleanupSemanticLossless, _diff_cleanupMerge, _diff_commonPrefix, _diff_commonSuffix, _diff_commonOverlap, _diff_xIndex, _diff_text1, _diff_text2, _diff_toDelta, _diff_fromDelta - ---[[ -* Find the differences between two texts. Simplifies the problem by stripping -* any common prefix or suffix off the texts before diffing. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} opt_checklines Has no effect in Lua. -* @param {number} opt_deadline Optional time when the diff should be complete -* by. Used internally for recursive calls. Users should set DiffTimeout -* instead. -* @return {Array.>} Array of diff tuples. ---]] -function diff_main(text1, text2, opt_checklines, opt_deadline) - -- Set a deadline by which time the diff must be complete. - if opt_deadline == nil then - if Diff_Timeout <= 0 then - opt_deadline = 2 ^ 31 - else - opt_deadline = clock() + Diff_Timeout - end - end - local deadline = opt_deadline - - -- Check for null inputs. - if text1 == nil or text2 == nil then - error("Null inputs. (diff_main)") - end - - -- Check for equality (speedup). - if text1 == text2 then - if #text1 > 0 then - return { { DIFF_EQUAL, text1 } } - end - return {} - end - - -- LUANOTE: Due to the lack of Unicode support, Lua is incapable of - -- implementing the line-mode speedup. - local checklines = false - - -- Trim off common prefix (speedup). - local commonlength = _diff_commonPrefix(text1, text2) - local commonprefix - if commonlength > 0 then - commonprefix = strsub(text1, 1, commonlength) - text1 = strsub(text1, commonlength + 1) - text2 = strsub(text2, commonlength + 1) - end - - -- Trim off common suffix (speedup). - commonlength = _diff_commonSuffix(text1, text2) - local commonsuffix - if commonlength > 0 then - commonsuffix = strsub(text1, -commonlength) - text1 = strsub(text1, 1, -commonlength - 1) - text2 = strsub(text2, 1, -commonlength - 1) - end - - -- Compute the diff on the middle block. - local diffs = _diff_compute(text1, text2, checklines, deadline) - - -- Restore the prefix and suffix. - if commonprefix then - tinsert(diffs, 1, { DIFF_EQUAL, commonprefix }) - end - if commonsuffix then - diffs[#diffs + 1] = { DIFF_EQUAL, commonsuffix } - end - - _diff_cleanupMerge(diffs) - return diffs -end - ---[[ -* Reduce the number of edits by eliminating semantically trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupSemantic(diffs) - local changes = false - local equalities = {} -- Stack of indices where equalities are found. - local equalitiesLength = 0 -- Keeping our own length var is faster. - local lastEquality = nil - -- Always equal to diffs[equalities[equalitiesLength]][2] - local pointer = 1 -- Index of current position. - -- Number of characters that changed prior to the equality. - local length_insertions1 = 0 - local length_deletions1 = 0 - -- Number of characters that changed after the equality. - local length_insertions2 = 0 - local length_deletions2 = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - length_insertions1 = length_insertions2 - length_deletions1 = length_deletions2 - length_insertions2 = 0 - length_deletions2 = 0 - lastEquality = diffs[pointer][2] - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_INSERT then - length_insertions2 = length_insertions2 + #diffs[pointer][2] - else - length_deletions2 = length_deletions2 + #diffs[pointer][2] - end - -- Eliminate an equality that is smaller or equal to the edits on both - -- sides of it. - if - lastEquality - and (#lastEquality <= max(length_insertions1, length_deletions1)) - and (#lastEquality <= max(length_insertions2, length_deletions2)) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - -- Throw away the previous equality (it needs to be reevaluated). - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - length_insertions1, length_deletions1 = 0, 0 -- Reset the counters. - length_insertions2, length_deletions2 = 0, 0 - lastEquality = nil - changes = true - end - end - pointer = pointer + 1 - end - - -- Normalize the diff. - if changes then - _diff_cleanupMerge(diffs) - end - _diff_cleanupSemanticLossless(diffs) - - -- Find any overlaps between deletions and insertions. - -- e.g: abcxxxxxxdef - -- -> abcxxxdef - -- e.g: xxxabcdefxxx - -- -> defxxxabc - -- Only extract an overlap if it is as big as the edit ahead or behind it. - pointer = 2 - while diffs[pointer] do - if diffs[pointer - 1][1] == DIFF_DELETE and diffs[pointer][1] == DIFF_INSERT then - local deletion = diffs[pointer - 1][2] - local insertion = diffs[pointer][2] - local overlap_length1 = _diff_commonOverlap(deletion, insertion) - local overlap_length2 = _diff_commonOverlap(insertion, deletion) - if overlap_length1 >= overlap_length2 then - if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then - -- Overlap found. Insert an equality and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(insertion, 1, overlap_length1) }) - diffs[pointer - 1][2] = strsub(deletion, 1, #deletion - overlap_length1) - diffs[pointer + 1][2] = strsub(insertion, overlap_length1 + 1) - pointer = pointer + 1 - end - else - if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then - -- Reverse overlap found. - -- Insert an equality and swap and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(deletion, 1, overlap_length2) }) - diffs[pointer - 1] = { DIFF_INSERT, strsub(insertion, 1, #insertion - overlap_length2) } - diffs[pointer + 1] = { DIFF_DELETE, strsub(deletion, overlap_length2 + 1) } - pointer = pointer + 1 - end - end - pointer = pointer + 1 - end - pointer = pointer + 1 - end -end - ---[[ -* Reduce the number of edits by eliminating operationally trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupEfficiency(diffs) - local changes = false - -- Stack of indices where equalities are found. - local equalities = {} - -- Keeping our own length var is faster. - local equalitiesLength = 0 - -- Always equal to diffs[equalities[equalitiesLength]][2] - local lastEquality = nil - -- Index of current position. - local pointer = 1 - - -- The following four are really booleans but are stored as numbers because - -- they are used at one point like this: - -- - -- (pre_ins + pre_del + post_ins + post_del) == 3 - -- - -- ...i.e. checking that 3 of them are true and 1 of them is false. - - -- Is there an insertion operation before the last equality. - local pre_ins = 0 - -- Is there a deletion operation before the last equality. - local pre_del = 0 - -- Is there an insertion operation after the last equality. - local post_ins = 0 - -- Is there a deletion operation after the last equality. - local post_del = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - local diffText = diffs[pointer][2] - if (#diffText < Diff_EditCost) and (post_ins == 1 or post_del == 1) then - -- Candidate found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - pre_ins, pre_del = post_ins, post_del - lastEquality = diffText - else - -- Not a candidate, and can never become one. - equalitiesLength = 0 - lastEquality = nil - end - post_ins, post_del = 0, 0 - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_DELETE then - post_del = 1 - else - post_ins = 1 - end - --[[ - * Five types to be split: - * ABXYCD - * AXCD - * ABXC - * AXCD - * ABXC - --]] - if - lastEquality - and ( - (pre_ins + pre_del + post_ins + post_del == 4) - or ((#lastEquality < Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del == 3)) - ) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - lastEquality = nil - if (pre_ins == 1) and (pre_del == 1) then - -- No changes made which could affect previous entry, keep going. - post_ins, post_del = 1, 1 - equalitiesLength = 0 - else - -- Throw away the previous equality. - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - post_ins, post_del = 0, 0 - end - changes = true - end - end - pointer = pointer + 1 - end - - if changes then - _diff_cleanupMerge(diffs) - end -end - ---[[ -* Compute the Levenshtein distance; the number of inserted, deleted or -* substituted characters. -* @param {Array.>} diffs Array of diff tuples. -* @return {number} Number of changes. ---]] -function diff_levenshtein(diffs) - local levenshtein = 0 - local insertions, deletions = 0, 0 - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - insertions = insertions + #data - elseif op == DIFF_DELETE then - deletions = deletions + #data - elseif op == DIFF_EQUAL then - -- A deletion and an insertion is one substitution. - levenshtein = levenshtein + max(insertions, deletions) - insertions = 0 - deletions = 0 - end - end - levenshtein = levenshtein + max(insertions, deletions) - return levenshtein -end - ---[[ -* Convert a diff array into a pretty HTML report. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} HTML representation. ---]] -function diff_prettyHtml(diffs) - local html = {} - for x, diff in ipairs(diffs) do - local op = diff[1] -- Operation (insert, delete, equal) - local data = diff[2] -- Text of change. - local text = gsub(data, htmlEncode_pattern, htmlEncode_replace) - if op == DIFF_INSERT then - html[x] = '' .. text .. "" - elseif op == DIFF_DELETE then - html[x] = '' .. text .. "" - elseif op == DIFF_EQUAL then - html[x] = "" .. text .. "" - end - end - return tconcat(html) -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE DIFF FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Find the differences between two texts. Assumes that the texts do not -* have any common prefix or suffix. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} checklines Has no effect in Lua. -* @param {number} deadline Time when the diff should be complete by. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_compute(text1, text2, checklines, deadline) - if #text1 == 0 then - -- Just add some text (speedup). - return { { DIFF_INSERT, text2 } } - end - - if #text2 == 0 then - -- Just delete some text (speedup). - return { { DIFF_DELETE, text1 } } - end - - local diffs - - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - local i = indexOf(longtext, shorttext) - - if i ~= nil then - -- Shorter text is inside the longer text (speedup). - diffs = { - { DIFF_INSERT, strsub(longtext, 1, i - 1) }, - { DIFF_EQUAL, shorttext }, - { DIFF_INSERT, strsub(longtext, i + #shorttext) }, - } - -- Swap insertions for deletions if diff is reversed. - if #text1 > #text2 then - diffs[1][1], diffs[3][1] = DIFF_DELETE, DIFF_DELETE - end - return diffs - end - - if #shorttext == 1 then - -- Single character string. - -- After the previous speedup, the character can't be an equality. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } - end - - -- Check to see if the problem can be split in two. - do - local text1_a, text1_b, text2_a, text2_b, mid_common = _diff_halfMatch(text1, text2) - - if text1_a then - -- A half-match was found, sort out the return data. - -- Send both pairs off for separate processing. - local diffs_a = diff_main(text1_a, text2_a, checklines, deadline) - local diffs_b = diff_main(text1_b, text2_b, checklines, deadline) - -- Merge the results. - local diffs_a_len = #diffs_a - diffs = diffs_a - diffs[diffs_a_len + 1] = { DIFF_EQUAL, mid_common } - for i, b_diff in ipairs(diffs_b) do - diffs[diffs_a_len + 1 + i] = b_diff - end - return diffs - end - end - - return _diff_bisect(text1, text2, deadline) -end - ---[[ -* Find the 'middle snake' of a diff, split the problem in two -* and return the recursively constructed diff. -* See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {number} deadline Time at which to bail if not yet complete. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_bisect(text1, text2, deadline) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - local _sub, _element - local max_d = ceil((text1_length + text2_length) / 2) - local v_offset = max_d - local v_length = 2 * max_d - local v1 = {} - local v2 = {} - -- Setting all elements to -1 is faster in Lua than mixing integers and nil. - for x = 0, v_length - 1 do - v1[x] = -1 - v2[x] = -1 - end - v1[v_offset + 1] = 0 - v2[v_offset + 1] = 0 - local delta = text1_length - text2_length - -- If the total number of characters is odd, then - -- the front path will collide with the reverse path. - local front = (delta % 2 ~= 0) - -- Offsets for start and end of k loop. - -- Prevents mapping of space beyond the grid. - local k1start = 0 - local k1end = 0 - local k2start = 0 - local k2end = 0 - for d = 0, max_d - 1 do - -- Bail out if deadline is reached. - if clock() > deadline then - break - end - - -- Walk the front path one step. - for k1 = -d + k1start, d - k1end, 2 do - local k1_offset = v_offset + k1 - local x1 - if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then - x1 = v1[k1_offset + 1] - else - x1 = v1[k1_offset - 1] + 1 - end - local y1 = x1 - k1 - while (x1 <= text1_length) and (y1 <= text2_length) and (strsub(text1, x1, x1) == strsub(text2, y1, y1)) do - x1 = x1 + 1 - y1 = y1 + 1 - end - v1[k1_offset] = x1 - if x1 > text1_length + 1 then - -- Ran off the right of the graph. - k1end = k1end + 2 - elseif y1 > text2_length + 1 then - -- Ran off the bottom of the graph. - k1start = k1start + 2 - elseif front then - local k2_offset = v_offset + delta - k1 - if k2_offset >= 0 and k2_offset < v_length and v2[k2_offset] ~= -1 then - -- Mirror x2 onto top-left coordinate system. - local x2 = text1_length - v2[k2_offset] + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - - -- Walk the reverse path one step. - for k2 = -d + k2start, d - k2end, 2 do - local k2_offset = v_offset + k2 - local x2 - if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then - x2 = v2[k2_offset + 1] - else - x2 = v2[k2_offset - 1] + 1 - end - local y2 = x2 - k2 - while - (x2 <= text1_length) - and (y2 <= text2_length) - and (strsub(text1, -x2, -x2) == strsub(text2, -y2, -y2)) - do - x2 = x2 + 1 - y2 = y2 + 1 - end - v2[k2_offset] = x2 - if x2 > text1_length + 1 then - -- Ran off the left of the graph. - k2end = k2end + 2 - elseif y2 > text2_length + 1 then - -- Ran off the top of the graph. - k2start = k2start + 2 - elseif not front then - local k1_offset = v_offset + delta - k2 - if k1_offset >= 0 and k1_offset < v_length and v1[k1_offset] ~= -1 then - local x1 = v1[k1_offset] - local y1 = v_offset + x1 - k1_offset - -- Mirror x2 onto top-left coordinate system. - x2 = text1_length - x2 + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - end - -- Diff took too long and hit the deadline or - -- number of diffs equals number of characters, no commonality at all. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } -end - ---[[ - * Given the location of the 'middle snake', split the diff in two parts - * and recurse. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} x Index of split point in text1. - * @param {number} y Index of split point in text2. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {Array.>} Array of diff tuples. - * @private ---]] -function _diff_bisectSplit(text1, text2, x, y, deadline) - local text1a = strsub(text1, 1, x - 1) - local text2a = strsub(text2, 1, y - 1) - local text1b = strsub(text1, x) - local text2b = strsub(text2, y) - - -- Compute both diffs serially. - local diffs = diff_main(text1a, text2a, false, deadline) - local diffsb = diff_main(text1b, text2b, false, deadline) - - local diffs_len = #diffs - for i, v in ipairs(diffsb) do - diffs[diffs_len + i] = v - end - return diffs -end - ---[[ -* Determine the common prefix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the start of each -* string. ---]] -function _diff_commonPrefix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, 1) ~= strbyte(text2, 1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerstart = 1 - while pointermin < pointermid do - if strsub(text1, pointerstart, pointermid) == strsub(text2, pointerstart, pointermid) then - pointermin = pointermid - pointerstart = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine the common suffix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of each string. ---]] -function _diff_commonSuffix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, -1) ~= strbyte(text2, -1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerend = 1 - while pointermin < pointermid do - if strsub(text1, -pointermid, -pointerend) == strsub(text2, -pointermid, -pointerend) then - pointermin = pointermid - pointerend = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine if the suffix of one string is the prefix of another. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of the first -* string and the start of the second string. -* @private ---]] -function _diff_commonOverlap(text1, text2) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - -- Eliminate the null case. - if text1_length == 0 or text2_length == 0 then - return 0 - end - -- Truncate the longer string. - if text1_length > text2_length then - text1 = strsub(text1, text1_length - text2_length + 1) - elseif text1_length < text2_length then - text2 = strsub(text2, 1, text1_length) - end - local text_length = min(text1_length, text2_length) - -- Quick check for the worst case. - if text1 == text2 then - return text_length - end - - -- Start by looking for a single character match - -- and increase length until no match is found. - -- Performance analysis: https://neil.fraser.name/news/2010/11/04/ - local best = 0 - local length = 1 - while true do - local pattern = strsub(text1, text_length - length + 1) - local found = strfind(text2, pattern, 1, true) - if found == nil then - return best - end - length = length + found - 1 - if found == 1 or strsub(text1, text_length - length + 1) == strsub(text2, 1, length) then - best = length - length = length + 1 - end - end -end - ---[[ -* Does a substring of shorttext exist within longtext such that the substring -* is at least half the length of longtext? -* This speedup can produce non-minimal diffs. -* Closure, but does not reference any external variables. -* @param {string} longtext Longer string. -* @param {string} shorttext Shorter string. -* @param {number} i Start index of quarter length substring within longtext. -* @return {?Array.} Five element Array, containing the prefix of -* longtext, the suffix of longtext, the prefix of shorttext, the suffix -* of shorttext and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatchI(longtext, shorttext, i) - -- Start with a 1/4 length substring at position i as a seed. - local seed = strsub(longtext, i, i + floor(#longtext / 4)) - local j = 0 -- LUANOTE: do not change to 1, was originally -1 - local best_common = "" - local best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b - while true do - j = indexOf(shorttext, seed, j + 1) - if j == nil then - break - end - local prefixLength = _diff_commonPrefix(strsub(longtext, i), strsub(shorttext, j)) - local suffixLength = _diff_commonSuffix(strsub(longtext, 1, i - 1), strsub(shorttext, 1, j - 1)) - if #best_common < suffixLength + prefixLength then - best_common = strsub(shorttext, j - suffixLength, j - 1) .. strsub(shorttext, j, j + prefixLength - 1) - best_longtext_a = strsub(longtext, 1, i - suffixLength - 1) - best_longtext_b = strsub(longtext, i + prefixLength) - best_shorttext_a = strsub(shorttext, 1, j - suffixLength - 1) - best_shorttext_b = strsub(shorttext, j + prefixLength) - end - end - if #best_common * 2 >= #longtext then - return { best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common } - else - return nil - end -end - ---[[ -* Do the two texts share a substring which is at least half the length of the -* longer text? -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {?Array.} Five element Array, containing the prefix of -* text1, the suffix of text1, the prefix of text2, the suffix of -* text2 and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatch(text1, text2) - if Diff_Timeout <= 0 then - -- Don't risk returning a non-optimal diff if we have unlimited time. - return nil - end - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - if (#longtext < 4) or (#shorttext * 2 < #longtext) then - return nil -- Pointless. - end - - -- First check if the second quarter is the seed for a half-match. - local hm1 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 4)) - -- Check again based on the third quarter. - local hm2 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 2)) - local hm - if not hm1 and not hm2 then - return nil - elseif not hm2 then - hm = hm1 - elseif not hm1 then - hm = hm2 - else - -- Both matched. Select the longest. - hm = (#hm1[5] > #hm2[5]) and hm1 or hm2 - end - - -- A half-match was found, sort out the return data. - local text1_a, text1_b, text2_a, text2_b - if #text1 > #text2 then - text1_a, text1_b = hm[1], hm[2] - text2_a, text2_b = hm[3], hm[4] - else - text2_a, text2_b = hm[1], hm[2] - text1_a, text1_b = hm[3], hm[4] - end - local mid_common = hm[5] - return text1_a, text1_b, text2_a, text2_b, mid_common -end - ---[[ -* Given two strings, compute a score representing whether the internal -* boundary falls on logical boundaries. -* Scores range from 6 (best) to 0 (worst). -* @param {string} one First string. -* @param {string} two Second string. -* @return {number} The score. -* @private ---]] -function _diff_cleanupSemanticScore(one, two) - if (#one == 0) or (#two == 0) then - -- Edges are the best. - return 6 - end - - -- Each port of this function behaves slightly differently due to - -- subtle differences in each language's definition of things like - -- 'whitespace'. Since this function's purpose is largely cosmetic, - -- the choice has been made to use each language's native features - -- rather than force total conformity. - local char1 = strsub(one, -1) - local char2 = strsub(two, 1, 1) - local nonAlphaNumeric1 = strmatch(char1, "%W") - local nonAlphaNumeric2 = strmatch(char2, "%W") - local whitespace1 = nonAlphaNumeric1 and strmatch(char1, "%s") - local whitespace2 = nonAlphaNumeric2 and strmatch(char2, "%s") - local lineBreak1 = whitespace1 and strmatch(char1, "%c") - local lineBreak2 = whitespace2 and strmatch(char2, "%c") - local blankLine1 = lineBreak1 and strmatch(one, "\n\r?\n$") - local blankLine2 = lineBreak2 and strmatch(two, "^\r?\n\r?\n") - - if blankLine1 or blankLine2 then - -- Five points for blank lines. - return 5 - elseif lineBreak1 or lineBreak2 then - -- Four points for line breaks. - return 4 - elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then - -- Three points for end of sentences. - return 3 - elseif whitespace1 or whitespace2 then - -- Two points for whitespace. - return 2 - elseif nonAlphaNumeric1 or nonAlphaNumeric2 then - -- One point for non-alphanumeric. - return 1 - end - return 0 -end - ---[[ -* Look for single edits surrounded on both sides by equalities -* which can be shifted sideways to align the edit to a word boundary. -* e.g: The cat came. -> The cat came. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupSemanticLossless(diffs) - local pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while diffs[pointer + 1] do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - - local equality1 = prevDiff[2] - local edit = diff[2] - local equality2 = nextDiff[2] - - -- First, shift the edit as far left as possible. - local commonOffset = _diff_commonSuffix(equality1, edit) - if commonOffset > 0 then - local commonString = strsub(edit, -commonOffset) - equality1 = strsub(equality1, 1, -commonOffset - 1) - edit = commonString .. strsub(edit, 1, -commonOffset - 1) - equality2 = commonString .. equality2 - end - - -- Second, step character by character right, looking for the best fit. - local bestEquality1 = equality1 - local bestEdit = edit - local bestEquality2 = equality2 - local bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - - while strbyte(edit, 1) == strbyte(equality2, 1) do - equality1 = equality1 .. strsub(edit, 1, 1) - edit = strsub(edit, 2) .. strsub(equality2, 1, 1) - equality2 = strsub(equality2, 2) - local score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - -- The >= encourages trailing rather than leading whitespace on edits. - if score >= bestScore then - bestScore = score - bestEquality1 = equality1 - bestEdit = edit - bestEquality2 = equality2 - end - end - if prevDiff[2] ~= bestEquality1 then - -- We have an improvement, save it back to the diff. - if #bestEquality1 > 0 then - diffs[pointer - 1][2] = bestEquality1 - else - tremove(diffs, pointer - 1) - pointer = pointer - 1 - end - diffs[pointer][2] = bestEdit - if #bestEquality2 > 0 then - diffs[pointer + 1][2] = bestEquality2 - else - tremove(diffs, pointer + 1, 1) - pointer = pointer - 1 - end - end - end - pointer = pointer + 1 - end -end - ---[[ -* Reorder and merge like edit sections. Merge equalities. -* Any edit section can move as long as it doesn't cross an equality. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupMerge(diffs) - diffs[#diffs + 1] = { DIFF_EQUAL, "" } -- Add a dummy entry at the end. - local pointer = 1 - local count_delete, count_insert = 0, 0 - local text_delete, text_insert = "", "" - local commonlength - while diffs[pointer] do - local diff_type = diffs[pointer][1] - if diff_type == DIFF_INSERT then - count_insert = count_insert + 1 - text_insert = text_insert .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_DELETE then - count_delete = count_delete + 1 - text_delete = text_delete .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_EQUAL then - -- Upon reaching an equality, check for prior redundancies. - if count_delete + count_insert > 1 then - if (count_delete > 0) and (count_insert > 0) then - -- Factor out any common prefixies. - commonlength = _diff_commonPrefix(text_insert, text_delete) - if commonlength > 0 then - local back_pointer = pointer - count_delete - count_insert - if (back_pointer > 1) and (diffs[back_pointer - 1][1] == DIFF_EQUAL) then - diffs[back_pointer - 1][2] = diffs[back_pointer - 1][2] - .. strsub(text_insert, 1, commonlength) - else - tinsert(diffs, 1, { DIFF_EQUAL, strsub(text_insert, 1, commonlength) }) - pointer = pointer + 1 - end - text_insert = strsub(text_insert, commonlength + 1) - text_delete = strsub(text_delete, commonlength + 1) - end - -- Factor out any common suffixies. - commonlength = _diff_commonSuffix(text_insert, text_delete) - if commonlength ~= 0 then - diffs[pointer][2] = strsub(text_insert, -commonlength) .. diffs[pointer][2] - text_insert = strsub(text_insert, 1, -commonlength - 1) - text_delete = strsub(text_delete, 1, -commonlength - 1) - end - end - -- Delete the offending records and add the merged ones. - pointer = pointer - count_delete - count_insert - for i = 1, count_delete + count_insert do - tremove(diffs, pointer) - end - if #text_delete > 0 then - tinsert(diffs, pointer, { DIFF_DELETE, text_delete }) - pointer = pointer + 1 - end - if #text_insert > 0 then - tinsert(diffs, pointer, { DIFF_INSERT, text_insert }) - pointer = pointer + 1 - end - pointer = pointer + 1 - elseif (pointer > 1) and (diffs[pointer - 1][1] == DIFF_EQUAL) then - -- Merge this equality with the previous one. - diffs[pointer - 1][2] = diffs[pointer - 1][2] .. diffs[pointer][2] - tremove(diffs, pointer) - else - pointer = pointer + 1 - end - count_insert, count_delete = 0, 0 - text_delete, text_insert = "", "" - end - end - if diffs[#diffs][2] == "" then - diffs[#diffs] = nil -- Remove the dummy entry at the end. - end - - -- Second pass: look for single edits surrounded on both sides by equalities - -- which can be shifted sideways to eliminate an equality. - -- e.g: ABAC -> ABAC - local changes = false - pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while pointer < #diffs do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - local currentText = diff[2] - local prevText = prevDiff[2] - local nextText = nextDiff[2] - if #prevText == 0 then - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, -#prevText) == prevText then - -- Shift the edit over the previous equality. - diff[2] = prevText .. strsub(currentText, 1, -#prevText - 1) - nextDiff[2] = prevText .. nextDiff[2] - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, 1, #nextText) == nextText then - -- Shift the edit over the next equality. - prevDiff[2] = prevText .. nextText - diff[2] = strsub(currentText, #nextText + 1) .. nextText - tremove(diffs, pointer + 1) - changes = true - end - end - pointer = pointer + 1 - end - -- If shifts were made, the diff needs reordering and another shift sweep. - if changes then - -- LUANOTE: no return value, but necessary to use 'return' to get - -- tail calls. - return _diff_cleanupMerge(diffs) - end -end - ---[[ -* loc is a location in text1, compute and return the equivalent location in -* text2. -* e.g. 'The cat' vs 'The big cat', 1->1, 5->8 -* @param {Array.>} diffs Array of diff tuples. -* @param {number} loc Location within text1. -* @return {number} Location within text2. ---]] -function _diff_xIndex(diffs, loc) - local chars1 = 1 - local chars2 = 1 - local last_chars1 = 1 - local last_chars2 = 1 - local x - for _x, diff in ipairs(diffs) do - x = _x - if diff[1] ~= DIFF_INSERT then -- Equality or deletion. - chars1 = chars1 + #diff[2] - end - if diff[1] ~= DIFF_DELETE then -- Equality or insertion. - chars2 = chars2 + #diff[2] - end - if chars1 > loc then -- Overshot the location. - break - end - last_chars1 = chars1 - last_chars2 = chars2 - end - -- Was the location deleted? - if diffs[x + 1] and (diffs[x][1] == DIFF_DELETE) then - return last_chars2 - end - -- Add the remaining character length. - return last_chars2 + (loc - last_chars1) -end - ---[[ -* Compute and return the source text (all equalities and deletions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Source text. ---]] -function _diff_text1(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_INSERT then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Compute and return the destination text (all equalities and insertions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Destination text. ---]] -function _diff_text2(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_DELETE then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Crush the diff into an encoded string which describes the operations -* required to transform text1 into text2. -* E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. -* Operations are tab-separated. Inserted text is escaped using %xx notation. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Delta text. ---]] -function _diff_toDelta(diffs) - local text = {} - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - text[x] = "+" .. gsub(data, percentEncode_pattern, percentEncode_replace) - elseif op == DIFF_DELETE then - text[x] = "-" .. #data - elseif op == DIFF_EQUAL then - text[x] = "=" .. #data - end - end - return tconcat(text, "\t") -end - ---[[ -* Given the original text1, and an encoded string which describes the -* operations required to transform text1 into text2, compute the full diff. -* @param {string} text1 Source string for the diff. -* @param {string} delta Delta text. -* @return {Array.>} Array of diff tuples. -* @throws {Errorend If invalid input. ---]] -function _diff_fromDelta(text1, delta) - local diffs = {} - local diffsLength = 0 -- Keeping our own length var is faster - local pointer = 1 -- Cursor in text1 - for token in gmatch(delta, "[^\t]+") do - -- Each token begins with a one character parameter which specifies the - -- operation of this token (delete, insert, equality). - local tokenchar, param = strsub(token, 1, 1), strsub(token, 2) - if tokenchar == "+" then - local invalidDecode = false - local decoded = gsub(param, "%%(.?.?)", function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return "" - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error("Illegal escape in _diff_fromDelta: " .. param) - end - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_INSERT, decoded } - elseif (tokenchar == "-") or (tokenchar == "=") then - local n = tonumber(param) - if (n == nil) or (n < 0) then - error("Invalid number in _diff_fromDelta: " .. param) - end - local text = strsub(text1, pointer, pointer + n - 1) - pointer = pointer + n - if tokenchar == "=" then - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_EQUAL, text } - else - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_DELETE, text } - end - else - error("Invalid diff operation in _diff_fromDelta: " .. token) - end - end - if pointer ~= #text1 + 1 then - error("Delta length (" .. (pointer - 1) .. ") does not equal source text length (" .. #text1 .. ").") - end - return diffs -end - --- --------------------------------------------------------------------------- --- MATCH API --- --------------------------------------------------------------------------- - -local _match_bitap, _match_alphabet - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc'. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. ---]] -function match_main(text, pattern, loc) - -- Check for null inputs. - if text == nil or pattern == nil or loc == nil then - error("Null inputs. (match_main)") - end - - if text == pattern then - -- Shortcut (potentially not guaranteed by the algorithm) - return 1 - elseif #text == 0 then - -- Nothing to match. - return -1 - end - loc = max(1, min(loc, #text)) - if strsub(text, loc, loc + #pattern - 1) == pattern then - -- Perfect match at the perfect spot! (Includes case of null pattern) - return loc - else - -- Do a fuzzy compare. - return _match_bitap(text, pattern, loc) - end -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE MATCH FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Initialise the alphabet for the Bitap algorithm. -* @param {string} pattern The text to encode. -* @return {Object} Hash of character locations. -* @private ---]] -function _match_alphabet(pattern) - local s = {} - local i = 0 - for c in gmatch(pattern, ".") do - s[c] = bor(s[c] or 0, lshift(1, #pattern - i - 1)) - i = i + 1 - end - return s -end - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc' using the -* Bitap algorithm. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. -* @private ---]] -function _match_bitap(text, pattern, loc) - if #pattern > Match_MaxBits then - error("Pattern too long.") - end - - -- Initialise the alphabet. - local s = _match_alphabet(pattern) - - --[[ - * Compute and return the score for a match with e errors and x location. - * Accesses loc and pattern through being a closure. - * @param {number} e Number of errors in match. - * @param {number} x Location of match. - * @return {number} Overall score for match (0.0 = good, 1.0 = bad). - * @private - --]] - local function _match_bitapScore(e, x) - local accuracy = e / #pattern - local proximity = abs(loc - x) - if Match_Distance == 0 then - -- Dodge divide by zero error. - return (proximity == 0) and 1 or accuracy - end - return accuracy + (proximity / Match_Distance) - end - - -- Highest score beyond which we give up. - local score_threshold = Match_Threshold - -- Is there a nearby exact match? (speedup) - local best_loc = indexOf(text, pattern, loc) - if best_loc then - score_threshold = min(_match_bitapScore(0, best_loc), score_threshold) - -- LUANOTE: Ideally we'd also check from the other direction, but Lua - -- doesn't have an efficent lastIndexOf function. - end - - -- Initialise the bit arrays. - local matchmask = lshift(1, #pattern - 1) - best_loc = -1 - - local bin_min, bin_mid - local bin_max = #pattern + #text - local last_rd - for d = 0, #pattern - 1, 1 do - -- Scan for the best match; each iteration allows for one more error. - -- Run a binary search to determine how far from 'loc' we can stray at this - -- error level. - bin_min = 0 - bin_mid = bin_max - while bin_min < bin_mid do - if _match_bitapScore(d, loc + bin_mid) <= score_threshold then - bin_min = bin_mid - else - bin_max = bin_mid - end - bin_mid = floor(bin_min + (bin_max - bin_min) / 2) - end - -- Use the result from this iteration as the maximum for the next. - bin_max = bin_mid - local start = max(1, loc - bin_mid + 1) - local finish = min(loc + bin_mid, #text) + #pattern - - local rd = {} - for j = start, finish do - rd[j] = 0 - end - rd[finish + 1] = lshift(1, d) - 1 - for j = finish, start, -1 do - local charMatch = s[strsub(text, j - 1, j - 1)] or 0 - if d == 0 then -- First pass: exact match. - rd[j] = band(bor((rd[j + 1] * 2), 1), charMatch) - else - -- Subsequent passes: fuzzy match. - -- Functions instead of operators make this hella messy. - rd[j] = bor( - band(bor(lshift(rd[j + 1], 1), 1), charMatch), - bor(bor(lshift(bor(last_rd[j + 1], last_rd[j]), 1), 1), last_rd[j + 1]) - ) - end - if band(rd[j], matchmask) ~= 0 then - local score = _match_bitapScore(d, j - 1) - -- This match will almost certainly be better than any existing match. - -- But check anyway. - if score <= score_threshold then - -- Told you so. - score_threshold = score - best_loc = j - 1 - if best_loc > loc then - -- When passing loc, don't exceed our current distance from loc. - start = max(1, loc * 2 - best_loc) - else - -- Already passed loc, downhill from here on in. - break - end - end - end - end - -- No hope for a (better) match at greater error levels. - if _match_bitapScore(d + 1, loc) > score_threshold then - break - end - last_rd = rd - end - return best_loc -end - --- ----------------------------------------------------------------------------- --- PATCH API --- ----------------------------------------------------------------------------- - -local _patch_addContext, _patch_deepCopy, _patch_addPadding, _patch_splitMax, _patch_appendText, _new_patch_obj - ---[[ -* Compute a list of patches to turn text1 into text2. -* Use diffs if provided, otherwise compute it ourselves. -* There are four ways to call this function, depending on what data is -* available to the caller: -* Method 1: -* a = text1, b = text2 -* Method 2: -* a = diffs -* Method 3 (optimal): -* a = text1, b = diffs -* Method 4 (deprecated, use method 3): -* a = text1, b = text2, c = diffs -* -* @param {string|Array.>} a text1 (methods 1,3,4) or -* Array of diff tuples for text1 to text2 (method 2). -* @param {string|Array.>} opt_b text2 (methods 1,4) or -* Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). -* @param {string|Array.>} opt_c Array of diff tuples for -* text1 to text2 (method 4) or undefined (methods 1,2,3). -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function patch_make(a, opt_b, opt_c) - local text1, diffs - local type_a, type_b, type_c = type(a), type(opt_b), type(opt_c) - if (type_a == "string") and (type_b == "string") and (type_c == "nil") then - -- Method 1: text1, text2 - -- Compute diffs from text1 and text2. - text1 = a - diffs = diff_main(text1, opt_b, true) - if #diffs > 2 then - diff_cleanupSemantic(diffs) - diff_cleanupEfficiency(diffs) - end - elseif (type_a == "table") and (type_b == "nil") and (type_c == "nil") then - -- Method 2: diffs - -- Compute text1 from diffs. - diffs = a - text1 = _diff_text1(diffs) - elseif (type_a == "string") and (type_b == "table") and (type_c == "nil") then - -- Method 3: text1, diffs - text1 = a - diffs = opt_b - elseif (type_a == "string") and (type_b == "string") and (type_c == "table") then - -- Method 4: text1, text2, diffs - -- text2 is not used. - text1 = a - diffs = opt_c - else - error("Unknown call format to patch_make.") - end - - if diffs[1] == nil then - return {} -- Get rid of the null case. - end - - local patches = {} - local patch = _new_patch_obj() - local patchDiffLength = 0 -- Keeping our own length var is faster. - local char_count1 = 0 -- Number of characters into the text1 string. - local char_count2 = 0 -- Number of characters into the text2 string. - -- Start with text1 (prepatch_text) and apply the diffs until we arrive at - -- text2 (postpatch_text). We recreate the patches one by one to determine - -- context info. - local prepatch_text, postpatch_text = text1, text1 - for x, diff in ipairs(diffs) do - local diff_type, diff_text = diff[1], diff[2] - - if (patchDiffLength == 0) and (diff_type ~= DIFF_EQUAL) then - -- A new patch starts here. - patch.start1 = char_count1 + 1 - patch.start2 = char_count2 + 1 - end - - if diff_type == DIFF_INSERT then - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length2 = patch.length2 + #diff_text - postpatch_text = strsub(postpatch_text, 1, char_count2) - .. diff_text - .. strsub(postpatch_text, char_count2 + 1) - elseif diff_type == DIFF_DELETE then - patch.length1 = patch.length1 + #diff_text - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - postpatch_text = strsub(postpatch_text, 1, char_count2) - .. strsub(postpatch_text, char_count2 + #diff_text + 1) - elseif diff_type == DIFF_EQUAL then - if (#diff_text <= Patch_Margin * 2) and (patchDiffLength ~= 0) and (#diffs ~= x) then - -- Small equality inside a patch. - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length1 = patch.length1 + #diff_text - patch.length2 = patch.length2 + #diff_text - elseif #diff_text >= Patch_Margin * 2 then - -- Time for a new patch. - if patchDiffLength ~= 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - patch = _new_patch_obj() - patchDiffLength = 0 - -- Unlike Unidiff, our patch lists have a rolling context. - -- https://github.com/google/diff-match-patch/wiki/Unidiff - -- Update prepatch text & pos to reflect the application of the - -- just completed patch. - prepatch_text = postpatch_text - char_count1 = char_count2 - end - end - end - - -- Update the current character count. - if diff_type ~= DIFF_INSERT then - char_count1 = char_count1 + #diff_text - end - if diff_type ~= DIFF_DELETE then - char_count2 = char_count2 + #diff_text - end - end - - -- Pick up the leftover patch if not empty. - if patchDiffLength > 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - end - - return patches -end - ---[[ -* Merge a set of patches onto the text. Return a patched text, as well -* as a list of true/false values indicating which patches were applied. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @param {string} text Old text. -* @return {Array.>} Two return values, the -* new text and an array of boolean values. ---]] -function patch_apply(patches, text) - if patches[1] == nil then - return text, {} - end - - -- Deep copy the patches so that no changes are made to originals. - patches = _patch_deepCopy(patches) - - local nullPadding = _patch_addPadding(patches) - text = nullPadding .. text .. nullPadding - - _patch_splitMax(patches) - -- delta keeps track of the offset between the expected and actual location - -- of the previous patch. If there are patches expected at positions 10 and - -- 20, but the first patch was found at 12, delta is 2 and the second patch - -- has an effective expected position of 22. - local delta = 0 - local results = {} - for x, patch in ipairs(patches) do - local expected_loc = patch.start2 + delta - local text1 = _diff_text1(patch.diffs) - local start_loc - local end_loc = -1 - if #text1 > Match_MaxBits then - -- _patch_splitMax will only provide an oversized pattern in - -- the case of a monster delete. - start_loc = match_main(text, strsub(text1, 1, Match_MaxBits), expected_loc) - if start_loc ~= -1 then - end_loc = match_main(text, strsub(text1, -Match_MaxBits), expected_loc + #text1 - Match_MaxBits) - if end_loc == -1 or start_loc >= end_loc then - -- Can't find valid trailing context. Drop this patch. - start_loc = -1 - end - end - else - start_loc = match_main(text, text1, expected_loc) - end - if start_loc == -1 then - -- No match found. :( - results[x] = false - -- Subtract the delta for this failed patch from subsequent patches. - delta = delta - patch.length2 - patch.length1 - else - -- Found a match. :) - results[x] = true - delta = start_loc - expected_loc - local text2 - if end_loc == -1 then - text2 = strsub(text, start_loc, start_loc + #text1 - 1) - else - text2 = strsub(text, start_loc, end_loc + Match_MaxBits - 1) - end - if text1 == text2 then - -- Perfect match, just shove the replacement text in. - text = strsub(text, 1, start_loc - 1) .. _diff_text2(patch.diffs) .. strsub(text, start_loc + #text1) - else - -- Imperfect match. Run a diff to get a framework of equivalent - -- indices. - local diffs = diff_main(text1, text2, false) - if (#text1 > Match_MaxBits) and (diff_levenshtein(diffs) / #text1 > Patch_DeleteThreshold) then - -- The end points match, but the content is unacceptably bad. - results[x] = false - else - _diff_cleanupSemanticLossless(diffs) - local index1 = 1 - local index2 - for y, mod in ipairs(patch.diffs) do - if mod[1] ~= DIFF_EQUAL then - index2 = _diff_xIndex(diffs, index1) - end - if mod[1] == DIFF_INSERT then - text = strsub(text, 1, start_loc + index2 - 2) - .. mod[2] - .. strsub(text, start_loc + index2 - 1) - elseif mod[1] == DIFF_DELETE then - text = strsub(text, 1, start_loc + index2 - 2) - .. strsub(text, start_loc + _diff_xIndex(diffs, index1 + #mod[2] - 1)) - end - if mod[1] ~= DIFF_DELETE then - index1 = index1 + #mod[2] - end - end - end - end - end - end - -- Strip the padding off. - text = strsub(text, #nullPadding + 1, -#nullPadding - 1) - return text, results -end - ---[[ -* Take a list of patches and return a textual representation. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} Text representation of patches. ---]] -function patch_toText(patches) - local text = {} - for x, patch in ipairs(patches) do - _patch_appendText(patch, text) - end - return tconcat(text) -end - ---[[ -* Parse a textual representation of patches and return a list of patch objects. -* @param {string} textline Text representation of patches. -* @return {Array.<_new_patch_obj>} Array of patch objects. -* @throws {Error} If invalid input. ---]] -function patch_fromText(textline) - local patches = {} - if #textline == 0 then - return patches - end - local text = {} - for line in gmatch(textline, "([^\n]*)") do - text[#text + 1] = line - end - local textPointer = 1 - while textPointer <= #text do - local start1, length1, start2, length2 = strmatch(text[textPointer], "^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@$") - if start1 == nil then - error('Invalid patch string: "' .. text[textPointer] .. '"') - end - local patch = _new_patch_obj() - patches[#patches + 1] = patch - - start1 = tonumber(start1) - length1 = tonumber(length1) or 1 - if length1 == 0 then - start1 = start1 + 1 - end - patch.start1 = start1 - patch.length1 = length1 - - start2 = tonumber(start2) - length2 = tonumber(length2) or 1 - if length2 == 0 then - start2 = start2 + 1 - end - patch.start2 = start2 - patch.length2 = length2 - - textPointer = textPointer + 1 - - while true do - local line = text[textPointer] - if line == nil then - break - end - local sign - sign, line = strsub(line, 1, 1), strsub(line, 2) - - local invalidDecode = false - local decoded = gsub(line, "%%(.?.?)", function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return "" - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error("Illegal escape in patch_fromText: " .. line) - end - - line = decoded - - if sign == "-" then - -- Deletion. - patch.diffs[#patch.diffs + 1] = { DIFF_DELETE, line } - elseif sign == "+" then - -- Insertion. - patch.diffs[#patch.diffs + 1] = { DIFF_INSERT, line } - elseif sign == " " then - -- Minor equality. - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, line } - elseif sign == "@" then - -- Start of next patch. - break - elseif sign == "" then - -- Blank line? Whatever. - else - -- WTF? - error('Invalid patch mode "' .. sign .. '" in: ' .. line) - end - textPointer = textPointer + 1 - end - end - return patches -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE PATCH FUNCTIONS --- --------------------------------------------------------------------------- - -local patch_meta = { - __tostring = function(patch) - local buf = {} - _patch_appendText(patch, buf) - return tconcat(buf) - end, -} - ---[[ -* Class representing one patch operation. -* @constructor ---]] -function _new_patch_obj() - return setmetatable({ - --[[ @type {Array.>} ]] - diffs = {}, - --[[ @type {?number} ]] - start1 = 1, -- nil; - --[[ @type {?number} ]] - start2 = 1, -- nil; - --[[ @type {number} ]] - length1 = 0, - --[[ @type {number} ]] - length2 = 0, - }, patch_meta) -end - ---[[ -* Increase the context until it is unique, -* but don't let the pattern expand beyond Match_MaxBits. -* @param {_new_patch_obj} patch The patch to grow. -* @param {string} text Source text. -* @private ---]] -function _patch_addContext(patch, text) - if #text == 0 then - return - end - local pattern = strsub(text, patch.start2, patch.start2 + patch.length1 - 1) - local padding = 0 - - -- LUANOTE: Lua's lack of a lastIndexOf function results in slightly - -- different logic here than in other language ports. - -- Look for the first two matches of pattern in text. If two are found, - -- increase the pattern length. - local firstMatch = indexOf(text, pattern) - local secondMatch = nil - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - end - while (#pattern == 0 or secondMatch ~= nil) and (#pattern < Match_MaxBits - Patch_Margin - Patch_Margin) do - padding = padding + Patch_Margin - pattern = strsub(text, max(1, patch.start2 - padding), patch.start2 + patch.length1 - 1 + padding) - firstMatch = indexOf(text, pattern) - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - else - secondMatch = nil - end - end - -- Add one chunk for good luck. - padding = padding + Patch_Margin - - -- Add the prefix. - local prefix = strsub(text, max(1, patch.start2 - padding), patch.start2 - 1) - if #prefix > 0 then - tinsert(patch.diffs, 1, { DIFF_EQUAL, prefix }) - end - -- Add the suffix. - local suffix = strsub(text, patch.start2 + patch.length1, patch.start2 + patch.length1 - 1 + padding) - if #suffix > 0 then - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, suffix } - end - - -- Roll back the start points. - patch.start1 = patch.start1 - #prefix - patch.start2 = patch.start2 - #prefix - -- Extend the lengths. - patch.length1 = patch.length1 + #prefix + #suffix - patch.length2 = patch.length2 + #prefix + #suffix -end - ---[[ -* Given an array of patches, return another array that is identical. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function _patch_deepCopy(patches) - local patchesCopy = {} - for x, patch in ipairs(patches) do - local patchCopy = _new_patch_obj() - local diffsCopy = {} - for i, diff in ipairs(patch.diffs) do - diffsCopy[i] = { diff[1], diff[2] } - end - patchCopy.diffs = diffsCopy - patchCopy.start1 = patch.start1 - patchCopy.start2 = patch.start2 - patchCopy.length1 = patch.length1 - patchCopy.length2 = patch.length2 - patchesCopy[x] = patchCopy - end - return patchesCopy -end - ---[[ -* Add some padding on text start and end so that edges can match something. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} The padding string added to each side. ---]] -function _patch_addPadding(patches) - local paddingLength = Patch_Margin - local nullPadding = "" - for x = 1, paddingLength do - nullPadding = nullPadding .. strchar(x) - end - - -- Bump all the patches forward. - for x, patch in ipairs(patches) do - patch.start1 = patch.start1 + paddingLength - patch.start2 = patch.start2 + paddingLength - end - - -- Add some padding on start of first diff. - local patch = patches[1] - local diffs = patch.diffs - local firstDiff = diffs[1] - if (firstDiff == nil) or (firstDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - tinsert(diffs, 1, { DIFF_EQUAL, nullPadding }) - patch.start1 = patch.start1 - paddingLength -- Should be 0. - patch.start2 = patch.start2 - paddingLength -- Should be 0. - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #firstDiff[2] then - -- Grow first equality. - local extraLength = paddingLength - #firstDiff[2] - firstDiff[2] = strsub(nullPadding, #firstDiff[2] + 1) .. firstDiff[2] - patch.start1 = patch.start1 - extraLength - patch.start2 = patch.start2 - extraLength - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - -- Add some padding on end of last diff. - patch = patches[#patches] - diffs = patch.diffs - local lastDiff = diffs[#diffs] - if (lastDiff == nil) or (lastDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - diffs[#diffs + 1] = { DIFF_EQUAL, nullPadding } - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #lastDiff[2] then - -- Grow last equality. - local extraLength = paddingLength - #lastDiff[2] - lastDiff[2] = lastDiff[2] .. strsub(nullPadding, 1, extraLength) - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - return nullPadding -end - ---[[ -* Look through the patches and break up any which are longer than the maximum -* limit of the match algorithm. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. ---]] -function _patch_splitMax(patches) - local patch_size = Match_MaxBits - local x = 1 - while true do - local patch = patches[x] - if patch == nil then - return - end - if patch.length1 > patch_size then - local bigpatch = patch - -- Remove the big old patch. - tremove(patches, x) - x = x - 1 - local start1 = bigpatch.start1 - local start2 = bigpatch.start2 - local precontext = "" - while bigpatch.diffs[1] do - -- Create one of several smaller patches. - local patch = _new_patch_obj() - local empty = true - patch.start1 = start1 - #precontext - patch.start2 = start2 - #precontext - if precontext ~= "" then - patch.length1, patch.length2 = #precontext, #precontext - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, precontext } - end - while bigpatch.diffs[1] and (patch.length1 < patch_size - Patch_Margin) do - local diff_type = bigpatch.diffs[1][1] - local diff_text = bigpatch.diffs[1][2] - if diff_type == DIFF_INSERT then - -- Insertions are harmless. - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - patch.diffs[#patch.diffs + 1] = bigpatch.diffs[1] - tremove(bigpatch.diffs, 1) - empty = false - elseif - (diff_type == DIFF_DELETE) - and (#patch.diffs == 1) - and (patch.diffs[1][1] == DIFF_EQUAL) - and (#diff_text > 2 * patch_size) - then - -- This is a large deletion. Let it pass in one chunk. - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - empty = false - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - tremove(bigpatch.diffs, 1) - else - -- Deletion or equality. - -- Only take as much as we can stomach. - diff_text = strsub(diff_text, 1, patch_size - patch.length1 - Patch_Margin) - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - if diff_type == DIFF_EQUAL then - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - else - empty = false - end - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - if diff_text == bigpatch.diffs[1][2] then - tremove(bigpatch.diffs, 1) - else - bigpatch.diffs[1][2] = strsub(bigpatch.diffs[1][2], #diff_text + 1) - end - end - end - -- Compute the head context for the next patch. - precontext = _diff_text2(patch.diffs) - precontext = strsub(precontext, -Patch_Margin) - -- Append the end context for this patch. - local postcontext = strsub(_diff_text1(bigpatch.diffs), 1, Patch_Margin) - if postcontext ~= "" then - patch.length1 = patch.length1 + #postcontext - patch.length2 = patch.length2 + #postcontext - if patch.diffs[1] and (patch.diffs[#patch.diffs][1] == DIFF_EQUAL) then - patch.diffs[#patch.diffs][2] = patch.diffs[#patch.diffs][2] .. postcontext - else - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, postcontext } - end - end - if not empty then - x = x + 1 - tinsert(patches, x, patch) - end - end - end - x = x + 1 - end -end - ---[[ -* Emulate GNU diff's format. -* Header: @@ -382,8 +481,9 @@ -* @return {string} The GNU diff string. ---]] -function _patch_appendText(patch, text) - local coords1, coords2 - local length1, length2 = patch.length1, patch.length2 - local start1, start2 = patch.start1, patch.start2 - local diffs = patch.diffs - - if length1 == 1 then - coords1 = start1 - else - coords1 = ((length1 == 0) and (start1 - 1) or start1) .. "," .. length1 - end - - if length2 == 1 then - coords2 = start2 - else - coords2 = ((length2 == 0) and (start2 - 1) or start2) .. "," .. length2 - end - text[#text + 1] = "@@ -" .. coords1 .. " +" .. coords2 .. " @@\n" - - local op - -- Escape the body of the patch with %xx notation. - for x, diff in ipairs(patch.diffs) do - local diff_type = diff[1] - if diff_type == DIFF_INSERT then - op = "+" - elseif diff_type == DIFF_DELETE then - op = "-" - elseif diff_type == DIFF_EQUAL then - op = " " - end - text[#text + 1] = op .. gsub(diffs[x][2], percentEncode_pattern, percentEncode_replace) .. "\n" - end - - return text -end - --- Expose the API -local _M = {} - -_M.DIFF_DELETE = DIFF_DELETE -_M.DIFF_INSERT = DIFF_INSERT -_M.DIFF_EQUAL = DIFF_EQUAL - -_M.diff_main = diff_main -_M.diff_cleanupSemantic = diff_cleanupSemantic -_M.diff_cleanupEfficiency = diff_cleanupEfficiency -_M.diff_levenshtein = diff_levenshtein -_M.diff_prettyHtml = diff_prettyHtml - -_M.match_main = match_main - -_M.patch_make = patch_make -_M.patch_toText = patch_toText -_M.patch_fromText = patch_fromText -_M.patch_apply = patch_apply - --- Expose some non-API functions as well, for testing purposes etc. -_M.diff_commonPrefix = _diff_commonPrefix -_M.diff_commonSuffix = _diff_commonSuffix -_M.diff_commonOverlap = _diff_commonOverlap -_M.diff_halfMatch = _diff_halfMatch -_M.diff_bisect = _diff_bisect -_M.diff_cleanupMerge = _diff_cleanupMerge -_M.diff_cleanupSemanticLossless = _diff_cleanupSemanticLossless -_M.diff_text1 = _diff_text1 -_M.diff_text2 = _diff_text2 -_M.diff_toDelta = _diff_toDelta -_M.diff_fromDelta = _diff_fromDelta -_M.diff_xIndex = _diff_xIndex -_M.match_alphabet = _match_alphabet -_M.match_bitap = _match_bitap -_M.new_patch_obj = _new_patch_obj -_M.patch_addContext = _patch_addContext -_M.patch_splitMax = _patch_splitMax -_M.patch_addPadding = _patch_addPadding -_M.settings = settings - -return _M diff --git a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua new file mode 100644 index 000000000..e607fa0de --- /dev/null +++ b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua @@ -0,0 +1,441 @@ +--[[ + Based on DiffMatchPatch by Neil Fraser. + https://github.com/google/diff-match-patch +]] + +export type DiffAction = number +export type Diff = { actionType: DiffAction, value: string } +export type Diffs = { Diff } + +local StringDiff = { + ActionTypes = table.freeze({ + Equal = 0, + Delete = 1, + Insert = 2, + }), +} + +function StringDiff.findDiffs(text1: string, text2: string): Diffs + -- Validate inputs + if type(text1) ~= "string" or type(text2) ~= "string" then + error( + string.format( + "Invalid inputs to StringDiff.findDiffs, expected strings and got (%s, %s)", + type(text1), + type(text2) + ), + 2 + ) + end + + -- Shortcut if the texts are identical + if text1 == text2 then + return { { actionType = StringDiff.ActionTypes.Equal, value = text1 } } + end + + -- Trim off any shared prefix and suffix + -- These are easy to detect and can be dealt with quickly without needing a complex diff + -- and later we simply add them as Equal to the start and end of the diff + local sharedPrefix, sharedSuffix + local prefixLength = StringDiff._sharedPrefix(text1, text2) + if prefixLength > 0 then + -- Store the prefix + sharedPrefix = string.sub(text1, 1, prefixLength) + -- Now trim it off + text1 = string.sub(text1, prefixLength + 1) + text2 = string.sub(text2, prefixLength + 1) + end + + local suffixLength = StringDiff._sharedSuffix(text1, text2) + if suffixLength > 0 then + -- Store the suffix + sharedSuffix = string.sub(text1, -suffixLength) + -- Now trim it off + text1 = string.sub(text1, 1, -suffixLength - 1) + text2 = string.sub(text2, 1, -suffixLength - 1) + end + + -- Compute the diff on the middle block where the changes lie + local diffs = StringDiff._computeDiff(text1, text2) + + -- Restore the prefix and suffix + if sharedPrefix then + table.insert(diffs, 1, { actionType = StringDiff.ActionTypes.Equal, value = sharedPrefix }) + end + if sharedSuffix then + table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = sharedSuffix }) + end + + -- Cleanup the diff + diffs = StringDiff._reorderAndMerge(diffs) + + return diffs +end + +function StringDiff._sharedPrefix(text1: string, text2: string): number + -- Uses a binary search to find the largest common prefix between the two strings + -- Performance analysis: http://neil.fraser.name/news/2007/10/09/ + + -- Shortcut common cases + if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, 1) ~= string.byte(text2, 1)) then + return 0 + end + + local pointerMin = 1 + local pointerMax = math.min(#text1, #text2) + local pointerMid = pointerMax + local pointerStart = 1 + while pointerMin < pointerMid do + if string.sub(text1, pointerStart, pointerMid) == string.sub(text2, pointerStart, pointerMid) then + pointerMin = pointerMid + pointerStart = pointerMin + else + pointerMax = pointerMid + end + pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2) + end + + return pointerMid +end + +function StringDiff._sharedSuffix(text1: string, text2: string): number + -- Uses a binary search to find the largest common suffix between the two strings + -- Performance analysis: http://neil.fraser.name/news/2007/10/09/ + + -- Shortcut common cases + if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, -1) ~= string.byte(text2, -1)) then + return 0 + end + + local pointerMin = 1 + local pointerMax = math.min(#text1, #text2) + local pointerMid = pointerMax + local pointerEnd = 1 + while pointerMin < pointerMid do + if string.sub(text1, -pointerMid, -pointerEnd) == string.sub(text2, -pointerMid, -pointerEnd) then + pointerMin = pointerMid + pointerEnd = pointerMin + else + pointerMax = pointerMid + end + pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2) + end + + return pointerMid +end + +function StringDiff._computeDiff(text1: string, text2: string): Diffs + -- Assumes that the prefix and suffix have already been trimmed off + -- and shortcut returns have been made so these texts must be different + + local text1Length, text2Length = #text1, #text2 + + if text1Length == 0 then + -- It's simply inserting all of text2 into text1 + return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } } + end + + if text2Length == 0 then + -- It's simply deleting all of text1 + return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } } + end + + local longText = if (text1Length > text2Length) then text1 else text2 + local shortText = if (text1Length > text2Length) then text2 else text1 + local shortTextLength = #shortText + + -- Shortcut if the shorter string exists entirely inside the longer one + local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true) + if indexOf ~= nil then + local diffs = { + { actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) }, + { actionType = StringDiff.ActionTypes.Equal, value = shortText }, + { actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) }, + } + -- Swap insertions for deletions if diff is reversed + if text1Length > text2Length then + diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete + end + return diffs + end + + if shortTextLength == 1 then + -- Single character string + -- After the previous shortcut, the character can't be an equality + return { + { actionType = StringDiff.ActionTypes.Delete, value = text1 }, + { actionType = StringDiff.ActionTypes.Insert, value = text2 }, + } + end + + return StringDiff._bisect(text1, text2) +end + +function StringDiff._bisect(text1: string, text2: string): Diffs + -- Find the 'middle snake' of a diff, split the problem in two + -- and return the recursively constructed diff + -- See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations + + -- Cache the text lengths to prevent multiple calls + local text1Length = #text1 + local text2Length = #text2 + + local _sub, _element + local maxD = math.ceil((text1Length + text2Length) / 2) + local vOffset = maxD + local vLength = 2 * maxD + local v1 = table.create(vLength) + local v2 = table.create(vLength) + + -- Setting all elements to -1 is faster in Lua than mixing integers and nil + for x = 0, vLength - 1 do + v1[x] = -1 + v2[x] = -1 + end + v1[vOffset + 1] = 0 + v2[vOffset + 1] = 0 + local delta = text1Length - text2Length + + -- If the total number of characters is odd, then + -- the front path will collide with the reverse path + local front = (delta % 2 ~= 0) + + -- Offsets for start and end of k loop + -- Prevents mapping of space beyond the grid + local k1Start = 0 + local k1End = 0 + local k2Start = 0 + local k2End = 0 + for d = 0, maxD - 1 do + -- Walk the front path one step + for k1 = -d + k1Start, d - k1End, 2 do + local k1_offset = vOffset + k1 + local x1 + if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then + x1 = v1[k1_offset + 1] + else + x1 = v1[k1_offset - 1] + 1 + end + local y1 = x1 - k1 + while (x1 <= text1Length) and (y1 <= text2Length) and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1)) do + x1 = x1 + 1 + y1 = y1 + 1 + end + v1[k1_offset] = x1 + if x1 > text1Length + 1 then + -- Ran off the right of the graph + k1End = k1End + 2 + elseif y1 > text2Length + 1 then + -- Ran off the bottom of the graph + k1Start = k1Start + 2 + elseif front then + local k2_offset = vOffset + delta - k1 + if k2_offset >= 0 and k2_offset < vLength and v2[k2_offset] ~= -1 then + -- Mirror x2 onto top-left coordinate system + local x2 = text1Length - v2[k2_offset] + 1 + if x1 > x2 then + -- Overlap detected + return StringDiff._bisectSplit(text1, text2, x1, y1) + end + end + end + end + + -- Walk the reverse path one step + for k2 = -d + k2Start, d - k2End, 2 do + local k2_offset = vOffset + k2 + local x2 + if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then + x2 = v2[k2_offset + 1] + else + x2 = v2[k2_offset - 1] + 1 + end + local y2 = x2 - k2 + while + (x2 <= text1Length) + and (y2 <= text2Length) + and (string.sub(text1, -x2, -x2) == string.sub(text2, -y2, -y2)) + do + x2 = x2 + 1 + y2 = y2 + 1 + end + v2[k2_offset] = x2 + if x2 > text1Length + 1 then + -- Ran off the left of the graph + k2End = k2End + 2 + elseif y2 > text2Length + 1 then + -- Ran off the top of the graph + k2Start = k2Start + 2 + elseif not front then + local k1_offset = vOffset + delta - k2 + if k1_offset >= 0 and k1_offset < vLength and v1[k1_offset] ~= -1 then + local x1 = v1[k1_offset] + local y1 = vOffset + x1 - k1_offset + -- Mirror x2 onto top-left coordinate system + x2 = text1Length - x2 + 1 + if x1 > x2 then + -- Overlap detected + return StringDiff._bisectSplit(text1, text2, x1, y1) + end + end + end + end + end + + -- Number of diffs equals number of characters, no commonality at all + return { + { actionType = StringDiff.ActionTypes.Delete, value = text1 }, + { actionType = StringDiff.ActionTypes.Insert, value = text2 }, + } +end + +function StringDiff._bisectSplit(text1: string, text2: string, x: number, y: number): Diffs + -- Given the location of the 'middle snake', + -- split the diff in two parts and recurse + + local text1a = string.sub(text1, 1, x - 1) + local text2a = string.sub(text2, 1, y - 1) + local text1b = string.sub(text1, x) + local text2b = string.sub(text2, y) + + -- Compute both diffs serially + local diffs = StringDiff.findDiffs(text1a, text2a) + local diffsB = StringDiff.findDiffs(text1b, text2b) + + -- Merge diffs + table.move(diffsB, 1, #diffsB, #diffs + 1, diffs) + return diffs +end + +function StringDiff._reorderAndMerge(diffs: Diffs): Diffs + -- Reorder and merge like edit sections and merge equalities + -- Any edit section can move as long as it doesn't cross an equality + + -- Add a dummy entry at the end + table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = "" }) + + local pointer = 1 + local countDelete, countInsert = 0, 0 + local textDelete, textInsert = "", "" + local commonLength + while diffs[pointer] do + local actionType = diffs[pointer].actionType + if actionType == StringDiff.ActionTypes.Insert then + countInsert = countInsert + 1 + textInsert = textInsert .. diffs[pointer].value + pointer = pointer + 1 + elseif actionType == StringDiff.ActionTypes.Delete then + countDelete = countDelete + 1 + textDelete = textDelete .. diffs[pointer].value + pointer = pointer + 1 + elseif actionType == StringDiff.ActionTypes.Equal then + -- Upon reaching an equality, check for prior redundancies + if countDelete + countInsert > 1 then + if (countDelete > 0) and (countInsert > 0) then + -- Factor out any common prefixies + commonLength = StringDiff._sharedPrefix(textInsert, textDelete) + if commonLength > 0 then + local back_pointer = pointer - countDelete - countInsert + if + (back_pointer > 1) and (diffs[back_pointer - 1].actionType == StringDiff.ActionTypes.Equal) + then + diffs[back_pointer - 1].value = diffs[back_pointer - 1].value + .. string.sub(textInsert, 1, commonLength) + else + table.insert( + diffs, + 1, + { + actionType = StringDiff.ActionTypes.Equal, + value = string.sub(textInsert, 1, commonLength), + } + ) + pointer = pointer + 1 + end + textInsert = string.sub(textInsert, commonLength + 1) + textDelete = string.sub(textDelete, commonLength + 1) + end + -- Factor out any common suffixies + commonLength = StringDiff._sharedSuffix(textInsert, textDelete) + if commonLength ~= 0 then + diffs[pointer].value = string.sub(textInsert, -commonLength) .. diffs[pointer].value + textInsert = string.sub(textInsert, 1, -commonLength - 1) + textDelete = string.sub(textDelete, 1, -commonLength - 1) + end + end + -- Delete the offending records and add the merged ones + pointer = pointer - countDelete - countInsert + for i = 1, countDelete + countInsert do + table.remove(diffs, pointer) + end + if #textDelete > 0 then + table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Delete, value = textDelete }) + pointer = pointer + 1 + end + if #textInsert > 0 then + table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Insert, value = textInsert }) + pointer = pointer + 1 + end + pointer = pointer + 1 + elseif (pointer > 1) and (diffs[pointer - 1].actionType == StringDiff.ActionTypes.Equal) then + -- Merge this equality with the previous one + diffs[pointer - 1].value = diffs[pointer - 1].value .. diffs[pointer].value + table.remove(diffs, pointer) + else + pointer = pointer + 1 + end + countInsert, countDelete = 0, 0 + textDelete, textInsert = "", "" + end + end + if diffs[#diffs].value == "" then + -- Remove the dummy entry at the end + diffs[#diffs] = nil + end + + -- Second pass: look for single edits surrounded on both sides by equalities + -- which can be shifted sideways to eliminate an equality + -- e.g: ABAC -> ABAC + local changes = false + pointer = 2 + -- Intentionally ignore the first and last element (don't need checking) + while pointer < #diffs do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if + (prevDiff.actionType == StringDiff.ActionTypes.Equal) + and (nextDiff.actionType == StringDiff.ActionTypes.Equal) + then + -- This is a single edit surrounded by equalities + local currentDiff = diffs[pointer] + local currentText = currentDiff.value + local prevText = prevDiff.value + local nextText = nextDiff.value + if #prevText == 0 then + table.remove(diffs, pointer - 1) + changes = true + elseif string.sub(currentText, -#prevText) == prevText then + -- Shift the edit over the previous equality + currentDiff.value = prevText .. string.sub(currentText, 1, -#prevText - 1) + nextDiff.value = prevText .. nextDiff.value + table.remove(diffs, pointer - 1) + changes = true + elseif string.sub(currentText, 1, #nextText) == nextText then + -- Shift the edit over the next equality + prevDiff.value = prevText .. nextText + currentDiff.value = string.sub(currentText, #nextText + 1) .. nextText + table.remove(diffs, pointer + 1) + changes = true + end + end + pointer = pointer + 1 + end + + -- If shifts were made, the diffs need reordering and another shift sweep + if changes then + return StringDiff._reorderAndMerge(diffs) + end + + return diffs +end + +return StringDiff diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 06d568076..605917e7a 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -7,7 +7,7 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) local Log = require(Packages.Log) local Highlighter = require(Packages.Highlighter) -local DMP = require(script:FindFirstChild("DiffMatchPatch")) +local StringDiff = require(script:FindFirstChild("StringDiff")) local Theme = require(Plugin.App.Theme) @@ -65,7 +65,7 @@ function StringDiffVisualizer:calculateDiffLines() -- Diff the two texts local startClock = os.clock() - local diffs = DMP.diff_main(oldText, newText) + local diffs = StringDiff.findDiffs(oldText, newText) local stopClock = os.clock() Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) @@ -75,13 +75,13 @@ function StringDiffVisualizer:calculateDiffLines() local oldLineNum, newLineNum = 1, 1 for _, diff in diffs do - local action, text = diff[1], diff[2] + local actionType, text = diff.actionType, diff.value local lines = select(2, string.gsub(text, "\n", "\n")) - if action == DMP.DIFF_EQUAL then + if actionType == StringDiff.ActionTypes.Equal then oldLineNum += lines newLineNum += lines - elseif action == DMP.DIFF_INSERT then + elseif actionType == StringDiff.ActionTypes.Insert then if lines > 0 then local textLines = string.split(text, "\n") for i, textLine in textLines do @@ -95,7 +95,7 @@ function StringDiffVisualizer:calculateDiffLines() end end newLineNum += lines - elseif action == DMP.DIFF_DELETE then + elseif actionType == StringDiff.ActionTypes.Delete then if lines > 0 then local textLines = string.split(text, "\n") for i, textLine in textLines do @@ -110,7 +110,7 @@ function StringDiffVisualizer:calculateDiffLines() end oldLineNum += lines else - Log.warn("Unknown diff action: {} {}", action, text) + Log.warn("Unknown diff action: {} {}", actionType, text) end end From c1bab28000fa5f807c4b92b29f3647513b1075e9 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 17:00:43 -0700 Subject: [PATCH 21/30] Remove unhelpful trace --- plugin/src/App/Components/StringDiffVisualizer/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 605917e7a..e802ab036 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -51,7 +51,7 @@ end function StringDiffVisualizer:calculateContentSize() local oldText, newText = self.props.oldText, self.props.newText - Log.trace("Calculating content size for {} {}", #oldText, #newText) + local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) From ddf468990dfe332d9cd44c3dccf857b8934fe880 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 17:01:41 -0700 Subject: [PATCH 22/30] Stylua formatting --- .../StringDiffVisualizer/StringDiff.lua | 22 +++++++++---------- .../Components/StringDiffVisualizer/init.lua | 10 +++++++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua index e607fa0de..f3321c230 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua @@ -140,8 +140,8 @@ function StringDiff._computeDiff(text1: string, text2: string): Diffs return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } } end - local longText = if (text1Length > text2Length) then text1 else text2 - local shortText = if (text1Length > text2Length) then text2 else text1 + local longText = if text1Length > text2Length then text1 else text2 + local shortText = if text1Length > text2Length then text2 else text1 local shortTextLength = #shortText -- Shortcut if the shorter string exists entirely inside the longer one @@ -217,7 +217,11 @@ function StringDiff._bisect(text1: string, text2: string): Diffs x1 = v1[k1_offset - 1] + 1 end local y1 = x1 - k1 - while (x1 <= text1Length) and (y1 <= text2Length) and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1)) do + while + (x1 <= text1Length) + and (y1 <= text2Length) + and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1)) + do x1 = x1 + 1 y1 = y1 + 1 end @@ -342,14 +346,10 @@ function StringDiff._reorderAndMerge(diffs: Diffs): Diffs diffs[back_pointer - 1].value = diffs[back_pointer - 1].value .. string.sub(textInsert, 1, commonLength) else - table.insert( - diffs, - 1, - { - actionType = StringDiff.ActionTypes.Equal, - value = string.sub(textInsert, 1, commonLength), - } - ) + table.insert(diffs, 1, { + actionType = StringDiff.ActionTypes.Equal, + value = string.sub(textInsert, 1, commonLength), + }) pointer = pointer + 1 end textInsert = string.sub(textInsert, commonLength + 1) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index e802ab036..5d165247b 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -68,7 +68,13 @@ function StringDiffVisualizer:calculateDiffLines() local diffs = StringDiff.findDiffs(oldText, newText) local stopClock = os.clock() - Log.trace("Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", #oldText, #newText, math.round((stopClock - startClock) * 1000 * 1000), #diffs) + Log.trace( + "Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", + #oldText, + #newText, + math.round((stopClock - startClock) * 1000 * 1000), + #diffs + ) -- Determine which lines to highlight local add, remove = {}, {} @@ -136,7 +142,7 @@ function StringDiffVisualizer:render() }, { UICorner = e("UICorner", { CornerRadius = UDim.new(0, 5), - }) + }), }), Separator = e("Frame", { Size = UDim2.new(0, 2, 1, 0), From 8ddc583c7917eed50d3875968772b5ca16db5cb7 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 17:02:06 -0700 Subject: [PATCH 23/30] Unused var --- plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua index f3321c230..6632c006a 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua @@ -365,7 +365,7 @@ function StringDiff._reorderAndMerge(diffs: Diffs): Diffs end -- Delete the offending records and add the merged ones pointer = pointer - countDelete - countInsert - for i = 1, countDelete + countInsert do + for _ = 1, countDelete + countInsert do table.remove(diffs, pointer) end if #textDelete > 0 then From e7fb8c6a72e137f8db4c464c05cdd7e8593e142f Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 17:10:11 -0700 Subject: [PATCH 24/30] Close diff viewer when patch is replaced --- plugin/src/App/StatusPages/Connected.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 54e2b9d14..d11eb1f5b 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -246,7 +246,11 @@ end function ConnectedPage:didUpdate(previousProps) if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then + -- New patch recieved self:startChangeInfoTextUpdater() + self:setState({ + showingSourceDiff = false, + }) end end From ab818cb54b38087d7f19f37394c87eecd1e1929f Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 17:17:29 -0700 Subject: [PATCH 25/30] Add to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e41a60d90..0507d7f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Added support for syncing in `.toml` files ([#633]) * Add `plugin` flag to the `build` command that outputs to the local plugins folder ([#735]) * Added better support for `Font` properties ([#731]) +* Added rich Source diffs in patch visualizer ([#748]) [#668]: https://github.com/rojo-rbx/rojo/pull/668 [#674]: https://github.com/rojo-rbx/rojo/pull/674 @@ -38,6 +39,7 @@ [#633]: https://github.com/rojo-rbx/rojo/pull/633 [#735]: https://github.com/rojo-rbx/rojo/pull/735 [#731]: https://github.com/rojo-rbx/rojo/pull/731 +[#748]: https://github.com/rojo-rbx/rojo/pull/748 ## [7.3.0] - April 22, 2023 * Added `$attributes` to project format. ([#574]) From a048a9cab24e88976eeab37634a1f01c0c59690c Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 18:53:16 -0700 Subject: [PATCH 26/30] Remove unused old initEnabled prop --- plugin/src/App/StatusPages/Confirming.lua | 2 -- plugin/src/App/StatusPages/Connected.lua | 1 - plugin/src/App/init.lua | 1 - 3 files changed, 4 deletions(-) diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 4848bdce1..4a258bbd9 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -142,7 +142,6 @@ function ConfirmingPage:render() active = self.state.showingSourceDiff, initDockState = Enum.InitialDockState.Float, - initEnabled = true, overridePreviousState = true, floatingSize = Vector2.new(500, 350), minimumSize = Vector2.new(400, 250), @@ -185,7 +184,6 @@ function ConfirmingPage:render() active = true, initDockState = Enum.InitialDockState.Float, - initEnabled = true, overridePreviousState = true, floatingSize = Vector2.new(500, 350), minimumSize = Vector2.new(400, 250), diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index d11eb1f5b..12ba2fc15 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -400,7 +400,6 @@ function ConnectedPage:render() active = self.state.showingSourceDiff, initDockState = Enum.InitialDockState.Float, - initEnabled = true, overridePreviousState = true, floatingSize = Vector2.new(500, 350), minimumSize = Vector2.new(400, 250), diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index f4c30f3c2..1dc88c1ea 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -554,7 +554,6 @@ function App:render() active = self.state.guiEnabled, initDockState = Enum.InitialDockState.Right, - initEnabled = false, overridePreviousState = false, floatingSize = Vector2.new(320, 210), minimumSize = Vector2.new(300, 210), From 443e810012cb57d524bc8a24e192e034809b90e1 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 18:56:06 -0700 Subject: [PATCH 27/30] Improve scriptBackground behavior and clarity --- .../src/App/Components/StringDiffVisualizer/init.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 5d165247b..481a69ddc 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -24,7 +24,7 @@ function StringDiffVisualizer:init() self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) self:calculateContentSize() - self:getScriptBackground() + self:updateScriptBackground() self:setState({ add = {}, @@ -32,12 +32,16 @@ function StringDiffVisualizer:init() }) end -function StringDiffVisualizer:getScriptBackground() - self.setScriptBackground(Highlighter.getTokenColor("background")) +function StringDiffVisualizer:updateScriptBackground() + local backgroundColor = Highlighter.getTokenColor("background") + if backgroundColor ~= self.scriptBackground:getValue() then + self.setScriptBackground(backgroundColor) + end end function StringDiffVisualizer:didUpdate(previousProps) - self:getScriptBackground() + -- Ensure that the script background is up to date with the current theme + self:updateScriptBackground() if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then self:calculateContentSize() From dbba28fa4d9140f75f3353be180d1263c2736a45 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 18:59:10 -0700 Subject: [PATCH 28/30] Fix imagecolor --- plugin/src/App/Components/PatchVisualizer/ChangeList.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua index 0fa58c9b0..7aac862a5 100644 --- a/plugin/src/App/Components/PatchVisualizer/ChangeList.lua +++ b/plugin/src/App/Components/PatchVisualizer/ChangeList.lua @@ -162,7 +162,7 @@ function ChangeList:render() }), Icon = e("ImageLabel", { Image = Assets.Images.Icons.Expand, - ImageColor3 = theme.Checkbox.Active.IconColor, + ImageColor3 = theme.Settings.Setting.DescriptionColor, ImageTransparency = self.props.transparency, Size = UDim2.new(0, 16, 0, 16), From 208526e857fa284b9f00bf0e6cd26e0d498340b1 Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 25 Jul 2023 19:07:50 -0700 Subject: [PATCH 29/30] Only update scriptBackground when truly needed --- .../App/Components/StringDiffVisualizer/init.lua | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugin/src/App/Components/StringDiffVisualizer/init.lua b/plugin/src/App/Components/StringDiffVisualizer/init.lua index 481a69ddc..cbdc7dec5 100644 --- a/plugin/src/App/Components/StringDiffVisualizer/init.lua +++ b/plugin/src/App/Components/StringDiffVisualizer/init.lua @@ -23,6 +23,14 @@ function StringDiffVisualizer:init() self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) + -- Ensure that the script background is up to date with the current theme + self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function() + task.defer(function() + -- Defer to allow Highlighter to process the theme change first + self:updateScriptBackground() + end) + end) + self:calculateContentSize() self:updateScriptBackground() @@ -32,6 +40,10 @@ function StringDiffVisualizer:init() }) end +function StringDiffVisualizer:willUnmount() + self.themeChangedConnection:Disconnect() +end + function StringDiffVisualizer:updateScriptBackground() local backgroundColor = Highlighter.getTokenColor("background") if backgroundColor ~= self.scriptBackground:getValue() then @@ -40,9 +52,6 @@ function StringDiffVisualizer:updateScriptBackground() end function StringDiffVisualizer:didUpdate(previousProps) - -- Ensure that the script background is up to date with the current theme - self:updateScriptBackground() - if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then self:calculateContentSize() local add, remove = self:calculateDiffLines() From bef3f051c66efc22c475e66e11674d56d310f1bd Mon Sep 17 00:00:00 2001 From: boatbomber Date: Wed, 26 Jul 2023 19:54:20 -0700 Subject: [PATCH 30/30] Remove unused var Co-authored-by: Micah --- plugin/src/App/Components/CodeLabel.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/plugin/src/App/Components/CodeLabel.lua b/plugin/src/App/Components/CodeLabel.lua index c683afa9a..192bcf3ad 100644 --- a/plugin/src/App/Components/CodeLabel.lua +++ b/plugin/src/App/Components/CodeLabel.lua @@ -1,5 +1,4 @@ local Rojo = script:FindFirstAncestor("Rojo") -local Plugin = Rojo.Plugin local Packages = Rojo.Packages local Roact = require(Packages.Roact)