├── .luacov ├── diff.cxx ├── LICENSE ├── CMakeLists.txt ├── .github └── workflows │ └── release.yml ├── README.md ├── init_test.lua └── init.lua /.luacov: -------------------------------------------------------------------------------- 1 | include = {'file_diff'} 2 | exclude = {'_test'} 3 | -------------------------------------------------------------------------------- /diff.cxx: -------------------------------------------------------------------------------- 1 | // Copyright 2017-2025 Mitchell. See LICENSE. 2 | 3 | #include "diff_match_patch.h" 4 | 5 | extern "C" { 6 | #include "lua.h" 7 | #include "lualib.h" 8 | #include "lauxlib.h" 9 | } 10 | 11 | /** diff() Lua function. */ 12 | static int diff(lua_State *L) { 13 | diff_match_patch dmp; 14 | auto diffs = dmp.diff_main(luaL_checkstring(L, 1), luaL_checkstring(L, 2), false); 15 | dmp.diff_cleanupSemantic(diffs); 16 | lua_createtable(L, diffs.size() * 2, 0); 17 | int len = 1; 18 | for (auto &diff : diffs) { 19 | lua_pushnumber(L, diff.operation), lua_rawseti(L, -2, len++); 20 | lua_pushstring(L, diff.text.c_str()), lua_rawseti(L, -2, len++); 21 | } 22 | return 1; 23 | } 24 | 25 | extern "C" { 26 | int luaopen_diff(lua_State *L) { return (lua_pushcfunction(L, diff), 1); } 27 | 28 | // Platform-specific Lua library entry points. 29 | LUALIB_API int luaopen_file_diff_diff(lua_State *L) { return luaopen_diff(L); } 30 | LUALIB_API int luaopen_file_diff_diffosx(lua_State *L) { return luaopen_diff(L); } 31 | LUALIB_API int luaopen_file_diff_diffarm(lua_State *L) { return luaopen_diff(L); } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2025 Mitchell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2022-2025 Mitchell. See LICENSE. 2 | 3 | cmake_minimum_required(VERSION 3.16) 4 | 5 | set(CMAKE_CXX_STANDARD 17) 6 | set(CMAKE_CXX_STANDARD_REQUIRED true) 7 | if(APPLE) 8 | set(CMAKE_OSX_DEPLOYMENT_TARGET 11 CACHE STRING "") 9 | endif() 10 | 11 | set(src ${CMAKE_SOURCE_DIR}) 12 | 13 | # Dependencies. 14 | include(FetchContent) 15 | set(FETCHCONTENT_QUIET OFF) 16 | set(diff_match_patch_zip 7f95b37e554453262e2bcda830724fc362614103.zip) 17 | FetchContent_Declare(diff_match_patch 18 | URL https://github.com/leutloff/diff-match-patch-cpp-stl/archive/${diff_match_patch_zip}) 19 | FetchContent_MakeAvailable(diff_match_patch) 20 | set(lua_tgz lua-5.5.0-rc1.tar.gz) 21 | set(lua_url file://${CMAKE_BINARY_DIR}/_deps/${lua_tgz}) 22 | if(NOT EXISTS ${CMAKE_BINARY_DIR}/_deps/${lua_tgz}) 23 | set(lua_url https://lua.org/work/${lua_tgz}) 24 | endif() 25 | FetchContent_Declare(lua URL ${lua_url}) 26 | FetchContent_MakeAvailable(lua) 27 | 28 | # Build. 29 | project(diff LANGUAGES CXX C) 30 | if(WIN32) 31 | # On Windows, DLLs cannot do dynamic lookup. They need symbols to link to at build time. 32 | # Rather than fetching a Textadept build and creating separate DLLs linked to textadept.lib and 33 | # textadept-curses.lib, just embed a minimal copy of Lua in a single DLL. 34 | file(GLOB lua_src ${lua_SOURCE_DIR}/src/*.c) 35 | list(FILTER lua_src EXCLUDE REGEX "(lua|luac|[^x]lib|linit)\.c$") # of *lib.c, keep only lauxlib.c 36 | endif() 37 | add_library(diff SHARED diff.cxx ${lua_src}) 38 | target_include_directories(diff PRIVATE ${diff_match_patch_SOURCE_DIR} ${lua_SOURCE_DIR}/src) 39 | if(WIN32) 40 | target_compile_definitions(diff PRIVATE LUA_BUILD_AS_DLL LUA_LIB) 41 | elseif(APPLE) 42 | target_link_options(diff PRIVATE -undefined dynamic_lookup) 43 | endif() 44 | 45 | # Install. 46 | install(TARGETS diff DESTINATION ${src}) 47 | if(NOT (WIN32 OR APPLE)) 48 | set(diff_so diff.so) 49 | if(CMAKE_SYSTEM_PROCESSOR MATCHES "^aarch") 50 | set(diff_so diffarm.so) 51 | endif() 52 | install(CODE "file(RENAME ${src}/libdiff.so ${src}/${diff_so})") 53 | include(GNUInstallDirs) 54 | set(module_dir ${CMAKE_INSTALL_FULL_DATADIR}/textadept/modules/file_diff) 55 | install(CODE "file(MAKE_DIRECTORY ${module_dir})") 56 | install(FILES init.lua ${diff_so} DESTINATION ${module_dir}) 57 | elseif(APPLE) 58 | install(CODE "file(RENAME ${src}/libdiff.dylib ${src}/diffosx.so)") 59 | endif() 60 | 61 | # Documentation. 62 | get_filename_component(ta_dir ${src}/../../ ABSOLUTE) 63 | add_custom_target(docs DEPENDS README.md) 64 | add_custom_command(OUTPUT ${src}/README.md 65 | COMMAND ldoc --filter markdowndoc.ldoc ${src}/init.lua -- --title="File Diff" --single 66 | > ${src}/README.md 67 | DEPENDS init.lua 68 | WORKING_DIRECTORY ${ta_dir}/scripts 69 | VERBATIM) 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: default 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-22.04, windows-2022, macOS-15, ubuntu-22.04-arm] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Checkout textadept-build dependencies 16 | uses: actions/checkout@v4 17 | with: 18 | repository: orbitalquark/textadept-build 19 | path: textadept-build 20 | - name: Build 21 | shell: bash 22 | run: | 23 | mkdir -p build/_deps && mv textadept-build/* build/_deps && rm -r textadept-build 24 | cmake -S . -B build -D CMAKE_INSTALL_PREFIX=build/install \ 25 | -D CMAKE_POLICY_VERSION_MINIMUM=3.5 # for diff-match-patch-cpp-stl 26 | cmake --build build --config Release --target diff -j 27 | cmake --install build --config Release 28 | - name: Upload artifacts 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: artifacts-${{ matrix.os }} 32 | path: | 33 | *.so 34 | *.dll 35 | release: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | - name: Download artifacts 42 | uses: actions/download-artifact@v4 43 | with: 44 | merge-multiple: true 45 | - name: Package 46 | shell: bash 47 | run: | 48 | git archive HEAD --prefix file_diff/ | tar -xf - 49 | mv *.so *.dll file_diff 50 | zip -r file_diff.zip file_diff 51 | - name: Tag 52 | run: | 53 | git tag latest 54 | git push -f origin latest 55 | - name: Create release 56 | uses: ncipollo/release-action@v1 57 | with: 58 | name: latest 59 | tag: latest 60 | allowUpdates: true 61 | body: | 62 | Latest automated build (ignore github-actions' release date) 63 | 64 | Note: this build may only be compatible with the latest release of Textadept 65 | (which may be an unstable release or a nightly build). If you are looking for a 66 | version of this module that is compatible with a specific version of Textadept, 67 | please download the "modules.zip" archive released alongside your version of Textadept. 68 | artifacts: file_diff.zip 69 | token: ${{ secrets.GITHUB_TOKEN }} 70 | cleanup: 71 | runs-on: ubuntu-latest 72 | needs: release 73 | steps: 74 | - name: Remove older build artifacts 75 | uses: c-hive/gha-remove-artifacts@v1 76 | with: 77 | age: '1 minute' 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Diff 2 | 3 | Two-way file comparison for Textadept. 4 | 5 | Install this module by copying it into your *~/.textadept/modules/* directory or Textadept's 6 | *modules/* directory, and then putting the following in your *~/.textadept/init.lua*: 7 | 8 | ```lua 9 | local file_diff = require('file_diff') 10 | ``` 11 | 12 | ## Compiling 13 | 14 | Releases include binaries, so building this modules should not be necessary. If you want 15 | to build manually, use CMake. For example: 16 | 17 | ```bash 18 | cmake -S . -B build_dir 19 | cmake --build build_dir --target diff 20 | cmake --install build_dir 21 | ``` 22 | 23 | ## Usage 24 | 25 | A sample workflow is this: 26 | 1. Start comparing two files via the "Compare Files" submenu in the "Tools" menu. 27 | 2. The caret is initially placed in the file on the left. 28 | 3. Go to the next change via menu or key binding. 29 | 4. Merge the change from the other buffer into the current one (right to left) via menu or 30 | key binding. 31 | 5. Go to the next change via menu or key binding. 32 | 6. Merge the change from the current buffer into the other one (left to right) via menu or 33 | key binding. 34 | 7. Repeat as necessary. 35 | 36 | Note: merging can be performed wherever the caret is placed when jumping between changes, 37 | even if one buffer has a change and the other does not (additions or deletions). 38 | 39 | ## Key Bindings 40 | 41 | Windows and Linux | macOS | Terminal | Command 42 | -|-|-|- 43 | **Tools**| | | 44 | F6 | F6 | None | Compare files... 45 | Shift+F6 | ⇧F6 | None | Compare the buffers in two split views 46 | Ctrl+F6 | ⌘F6 | None | Stop comparing 47 | Ctrl+Alt+. | ^⌘. | None | Goto next difference 48 | Ctrl+Alt+, | ^⌘, | None | Goto previous difference 49 | Ctrl+Alt+< | ^⌘< | None | Merge left 50 | Ctrl+Alt+> | ^⌘> | None | Merge right 51 | 52 | 53 | ## `file_diff.INDIC_ADDITION` 54 | 55 | The indicator number for text added within lines. 56 | 57 | 58 | ## `file_diff.INDIC_DELETION` 59 | 60 | The indicator number for text deleted within lines. 61 | 62 | 63 | ## `file_diff.MARK_ADDITION` 64 | 65 | The marker for line additions. 66 | 67 | 68 | ## `file_diff.MARK_DELETION` 69 | 70 | The marker for line deletions. 71 | 72 | 73 | ## `file_diff.MARK_MODIFICATION` 74 | 75 | The marker for line modifications. 76 | 77 | 78 | ## `_G.diff`(*text1*, *text2*) 79 | 80 | Returns a list of the differences between strings. 81 | 82 | Each consecutive pair of elements in the returned list represents a "diff". The first element 83 | is an integer: 0 for a deletion, 1 for an insertion, and 2 for equality. The second element 84 | is the associated diff text. 85 | 86 | Parameters: 87 | - *text1*: String to compare against. 88 | - *text2*: String to compare. 89 | 90 | Usage: 91 | 92 | ```lua 93 | diffs = diff(text1, text2) 94 | for i = 1, #diffs, 2 do print(diffs[i], diffs[i + 1]) end 95 | ``` 96 | 97 | 98 | ## `file_diff.addition_color_name` 99 | 100 | The name of the theme color used to mark additions. 101 | 102 | The default value is 'green'. If your theme does not define that color, set this field to 103 | your theme's equivalent. 104 | 105 | 106 | ## `file_diff.deletion_color_name` 107 | 108 | The name of the theme color used to mark deletions. 109 | 110 | The default value is 'red'. If your theme does not define that color, set this field to your 111 | theme's equivalent. 112 | 113 | 114 | ## `file_diff.goto_change`([*next*=false]) 115 | 116 | Jumps to the next or previous difference between the two files. 117 | 118 | [`file_diff.start()`](#file_diff.start) must have been called previously. 119 | 120 | Parameters: 121 | - *next*: Go to the next previous difference relative to the current line, 122 | as opposed to the previous one. 123 | 124 | 125 | ## `file_diff.merge`([*left*=false]) 126 | 127 | Merges a change from one buffer to another, depending on the change under the caret and the 128 | merge direction. 129 | 130 | Parameters: 131 | - *left*: Merge from right to left as opposed to left to right. 132 | 133 | 134 | ## `file_diff.modification_color_name` 135 | 136 | The name of the theme color used to mark modifications. 137 | 138 | The default value is 'yellow'. If your theme does not define that color, set this field to 139 | your theme's equivalent. 140 | 141 | 142 | ## `file_diff.start`([*file1*[, *file2*[, *horizontal*=false]]]) 143 | 144 | Highlight differences between files. 145 | 146 | Parameters: 147 | - *file1*: String older filename. If `-`, uses the current buffer. If `nil`, the user 148 | is prompted for a file. 149 | - *file2*: String newer filename. If `-`, uses the current buffer. If `nil`, the user 150 | is prompted for a file. 151 | - *horizontal*: Split the view horizontally instead of vertically. The 152 | default is to compare files side-by-side. 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /init_test.lua: -------------------------------------------------------------------------------- 1 | -- Copyright 2020-2025 Mitchell. See LICENSE. 2 | 3 | local file_diff = require('file_diff') 4 | 5 | test('file_diff.start should prompt for files to compare', function() 6 | local f1 = test.tmpfile() 7 | local f2 = test.tmpfile() 8 | local filenames = {f1.filename, f2.filename} 9 | local select_filename = function() return table.remove(filenames, 1) end 10 | local _ = test.mock(ui.dialogs, 'open', select_filename) 11 | 12 | file_diff.start() 13 | 14 | test.assert_equal(#_VIEWS, 2) 15 | test.assert_equal(_VIEWS[view], 1) 16 | test.assert_equal(view.buffer.filename, f1.filename) 17 | test.assert_equal(_VIEWS[2].buffer.filename, f2.filename) 18 | end) 19 | 20 | test('file_diff.start should use existing views', function() 21 | view:split() 22 | buffer.new() 23 | 24 | file_diff.start('-', '-') 25 | 26 | test.assert_equal(#_VIEWS, 2) 27 | test.assert_equal(_BUFFERS[buffer], 1) 28 | test.assert_equal(_BUFFERS[_VIEWS[2].buffer], 2) 29 | end) 30 | 31 | local original = test.lines{ 32 | '', -- 33 | 'modify', -- 34 | 'unchanged', -- 35 | 'deleted', -- 36 | '' 37 | } 38 | 39 | local changed = test.lines{ 40 | '', -- 41 | 'added', -- 42 | 'modified', -- 43 | 'unchanged', -- 44 | '' 45 | } 46 | 47 | local function start_basic_diff() 48 | buffer:append_text(original) 49 | view:split(true) 50 | buffer.new() 51 | buffer:append_text(changed) 52 | file_diff.start('-', '-') 53 | ui.goto_view(_VIEWS[1]) 54 | end 55 | 56 | test('file_diff should mark added, modified, and deleted lines', function() 57 | start_basic_diff() 58 | 59 | local added_lines1 = test.get_marked_lines(file_diff.MARK_ADDITION, _VIEWS[1].buffer) 60 | local added_lines2 = test.get_marked_lines(file_diff.MARK_ADDITION, _VIEWS[2].buffer) 61 | local modified_lines1 = test.get_marked_lines(file_diff.MARK_MODIFICATION, _VIEWS[1].buffer) 62 | local modified_lines2 = test.get_marked_lines(file_diff.MARK_MODIFICATION, _VIEWS[2].buffer) 63 | local deleted_lines1 = test.get_marked_lines(file_diff.MARK_DELETION, _VIEWS[1].buffer) 64 | local deleted_lines2 = test.get_marked_lines(file_diff.MARK_DELETION, _VIEWS[2].buffer) 65 | test.assert_equal(added_lines1, {}) 66 | test.assert_equal(added_lines2, {2}) 67 | test.assert_equal(modified_lines1, {2}) 68 | test.assert_equal(modified_lines2, {3}) 69 | test.assert_equal(deleted_lines1, {4}) 70 | test.assert_equal(deleted_lines2, {}) 71 | end) 72 | 73 | test('file_diff should indicated added and deleted ranges', function() 74 | start_basic_diff() 75 | 76 | local deleted1 = test.get_indicated_text(file_diff.INDIC_DELETION, _VIEWS[1].buffer) 77 | local deleted2 = test.get_indicated_text(file_diff.INDIC_DELETION, _VIEWS[2].buffer) 78 | local added1 = test.get_indicated_text(file_diff.INDIC_ADDITION, _VIEWS[1].buffer) 79 | local added2 = test.get_indicated_text(file_diff.INDIC_ADDITION, _VIEWS[2].buffer) 80 | test.assert_equal(deleted1, {'y'}) -- from modify 81 | test.assert_equal(deleted2, {}) 82 | test.assert_equal(added1, {}) 83 | test.assert_equal(added2, {'ied'}) -- from modified 84 | end) 85 | 86 | test('file_diff should add dummy lines in place of additions and deletions', function() 87 | start_basic_diff() 88 | 89 | test.assert_equal(_VIEWS[1].buffer.annotation_text[1], ' ') -- across from 'added' 90 | test.assert_equal(_VIEWS[2].buffer.annotation_text[4], ' ') -- across from 'deleted' 91 | end) 92 | 93 | test('file_diff.goto_change(true) should jump to the next change (left view)', function() 94 | start_basic_diff() 95 | local lines = {} 96 | 97 | for _ = 1, 3 do 98 | file_diff.goto_change(true) 99 | lines[#lines + 1] = buffer:line_from_position(buffer.current_pos) 100 | end 101 | 102 | test.assert_equal(lines, {2, 4, 1}) -- wraps around back to 1 103 | end) 104 | 105 | test('file_diff.goto_change should jump to the previous change (left view)', function() 106 | start_basic_diff() 107 | local lines = {} 108 | 109 | for _ = 1, 3 do 110 | file_diff.goto_change() 111 | lines[#lines + 1] = buffer:line_from_position(buffer.current_pos) 112 | end 113 | 114 | test.assert_equal(lines, {4, 2, 1}) 115 | end) 116 | expected_failure() 117 | 118 | test('file_diff.goto_change(true) should jump to the next change (right view)', function() 119 | start_basic_diff() 120 | ui.goto_view(_VIEWS[2]) 121 | local lines = {} 122 | 123 | for _ = 1, 3 do 124 | file_diff.goto_change(true) 125 | lines[#lines + 1] = buffer:line_from_position(buffer.current_pos) 126 | end 127 | 128 | test.assert_equal(lines, {2, 3, 4}) 129 | end) 130 | 131 | test('file_diff.goto_change should jump to the previous change (right view)', function() 132 | start_basic_diff() 133 | ui.goto_view(_VIEWS[2]) 134 | local lines = {} 135 | 136 | for _ = 1, 3 do 137 | file_diff.goto_change() 138 | lines[#lines + 1] = buffer:line_from_position(buffer.current_pos) 139 | end 140 | 141 | test.assert_equal(lines, {4, 3, 2}) 142 | end) 143 | 144 | test('file_diff.goto_change should treat multi-line changes as a single change', function() 145 | buffer:append_text(test.lines{ 146 | '', -- 147 | 'unchanged', -- 148 | 'modify', -- 149 | 'modify', -- 150 | 'unchanged', -- 151 | 'deleted', -- 152 | 'deleted', -- 153 | '' 154 | }) 155 | view:split(true) 156 | buffer.new() 157 | buffer:append_text(test.lines{ 158 | '', -- 159 | 'added', -- 160 | 'added', -- 161 | 'unchanged', -- 162 | 'modified', -- 163 | 'modified', -- 164 | 'unchanged', -- 165 | '' 166 | }) 167 | file_diff.start('-', '-') 168 | ui.goto_view(_VIEWS[1]) 169 | local lines = {} 170 | 171 | for _ = 1, 3 do 172 | file_diff.goto_change(true) 173 | lines[#lines + 1] = buffer:line_from_position(buffer.current_pos) 174 | end 175 | 176 | test.assert_equal(lines, {3, 6, 1}) 177 | end) 178 | 179 | test('file_diff.merge should merge from left to right (left view)', function() 180 | start_basic_diff() 181 | 182 | file_diff.merge() 183 | 184 | test.assert_equal(_VIEWS[view], 1) 185 | test.assert_equal(buffer:line_from_position(buffer.current_pos), 1) 186 | test.assert_equal(buffer:get_text(), original) 187 | test.assert_equal(_VIEWS[2].buffer:get_text(), test.lines{ 188 | '', -- 189 | 'modified', -- 190 | 'unchanged', -- 191 | '' 192 | }) 193 | 194 | file_diff.goto_change(true) 195 | file_diff.merge() 196 | 197 | test.assert_equal(buffer:get_text(), original) 198 | test.assert_equal(_VIEWS[2].buffer:get_text(), test.lines{ 199 | '', -- 200 | 'modify', -- 201 | 'unchanged', -- 202 | '' -- 203 | }) 204 | 205 | file_diff.goto_change(true) 206 | file_diff.merge() 207 | 208 | test.assert_equal(buffer:get_text(), original) 209 | test.assert_equal(_VIEWS[2].buffer:get_text(), original) 210 | end) 211 | 212 | test('file_diff.merge should merge from right to left (left view)', function() 213 | start_basic_diff() 214 | 215 | file_diff.merge(true) 216 | 217 | test.assert_equal(_VIEWS[view], 1) 218 | test.assert(buffer:line_from_position(buffer.current_pos), 2) 219 | test.assert_equal(buffer:get_text(), test.lines{ 220 | '', -- 221 | 'added', -- 222 | 'modify', -- 223 | 'unchanged', -- 224 | 'deleted', -- 225 | '' 226 | }) 227 | test.assert_equal(_VIEWS[2].buffer:get_text(), changed) 228 | 229 | file_diff.goto_change(true) 230 | file_diff.merge(true) 231 | 232 | test.assert_equal(buffer:get_text(), test.lines{ 233 | '', -- 234 | 'added', -- 235 | 'modified', -- 236 | 'unchanged', -- 237 | 'deleted', -- 238 | '' 239 | }) 240 | test.assert_equal(_VIEWS[2].buffer:get_text(), changed) 241 | 242 | file_diff.goto_change(true) 243 | file_diff.merge(true) 244 | 245 | test.assert_equal(buffer:get_text(), changed) 246 | test.assert_equal(_VIEWS[2].buffer:get_text(), changed) 247 | end) 248 | 249 | test('file_diff.merge should merge from left to right (right view)', function() 250 | start_basic_diff() 251 | ui.goto_view(_VIEWS[2]) 252 | 253 | file_diff.goto_change(true) 254 | file_diff.merge() 255 | 256 | test.assert_equal(_VIEWS[view], 2) 257 | test.assert_equal(buffer:line_from_position(buffer.current_pos), 2) 258 | test.assert_equal(buffer:get_text(), test.lines{ 259 | '', -- 260 | 'modified', -- 261 | 'unchanged', -- 262 | '' 263 | }) 264 | test.assert_equal(_VIEWS[1].buffer:get_text(), original) 265 | 266 | file_diff.merge() -- no need to go to next change 267 | 268 | test.assert_equal(buffer:get_text(), test.lines{ 269 | '', -- 270 | 'modify', -- 271 | 'unchanged', -- 272 | '' 273 | }) 274 | test.assert_equal(_VIEWS[1].buffer:get_text(), original) 275 | 276 | file_diff.goto_change(true) 277 | file_diff.merge() 278 | 279 | test.assert_equal(buffer:get_text(), original) 280 | test.assert_equal(_VIEWS[2].buffer:get_text(), original) 281 | end) 282 | 283 | test('file_diff.merge should merge from right to left (right view)', function() 284 | start_basic_diff() 285 | ui.goto_view(_VIEWS[2]) 286 | 287 | file_diff.goto_change(true) 288 | file_diff.merge(true) 289 | 290 | test.assert_equal(_VIEWS[view], 2) 291 | test.assert_equal(buffer:line_from_position(buffer.current_pos), 2) 292 | test.assert_equal(buffer:get_text(), changed) 293 | test.assert_equal(_VIEWS[1].buffer:get_text(), test.lines{ 294 | '', -- 295 | 'added', -- 296 | 'modify', -- 297 | 'unchanged', -- 298 | 'deleted', -- 299 | '' 300 | }) 301 | 302 | file_diff.goto_change(true) 303 | file_diff.merge(true) 304 | 305 | test.assert_equal(buffer:get_text(), changed) 306 | test.assert_equal(_VIEWS[1].buffer:get_text(), test.lines{ 307 | '', -- 308 | 'added', -- 309 | 'modified', -- 310 | 'unchanged', -- 311 | 'deleted', -- 312 | '' 313 | }) 314 | 315 | file_diff.goto_change(true) 316 | file_diff.merge(true) 317 | 318 | test.assert_equal(buffer:get_text(), changed) 319 | test.assert_equal(_VIEWS[1].buffer:get_text(), changed) 320 | end) 321 | 322 | test('file_diff should fill space for a change with additional lines in the right buffer', 323 | function() 324 | buffer:append_text(test.lines{ 325 | '', -- 326 | 'modify', -- 327 | 'unchanged' 328 | }) 329 | view:split(true) 330 | buffer.new() 331 | buffer:append_text(test.lines{ 332 | '', -- 333 | 'modified', -- 334 | 'added', -- 335 | 'unchanged' 336 | }) 337 | 338 | file_diff.start('-', '-') 339 | 340 | test.assert_equal(_VIEWS[1].buffer.annotation_text[2], ' ') 341 | end) 342 | 343 | test('file_diff should fill space for a change with additional lines in the left buffer', function() 344 | buffer:append_text(test.lines{ 345 | '', -- 346 | 'added', -- 347 | 'added', -- 348 | 'unchanged' 349 | }) 350 | view:split(true) 351 | buffer.new() 352 | buffer:append_text(test.lines{ 353 | '', -- 354 | '', -- 355 | 'unchanged' 356 | }) 357 | 358 | file_diff.start('-', '-') 359 | 360 | test.assert_equal(_VIEWS[2].buffer.annotation_text[2], ' ') 361 | end) 362 | 363 | test('file_diff should fill space for a larger change in the right buffer', function() 364 | buffer:append_text(test.lines{ 365 | '', -- 366 | 'modify', -- 367 | 'modify', -- 368 | 'unchanged' 369 | }) 370 | view:split(true) 371 | buffer.new() 372 | buffer:append_text(test.lines{ 373 | '', -- 374 | 'modified', -- 375 | 'unchanged' 376 | }) 377 | 378 | file_diff.start('-', '-') 379 | 380 | test.assert_equal(_VIEWS[2].buffer.annotation_text[2], ' ') 381 | end) 382 | 383 | test('file_diff should fill space for an addition with additional lines in the right buffer', 384 | function() 385 | buffer:append_text(test.lines{ 386 | '', -- 387 | 'modified', -- 388 | 'unchanged' 389 | }) 390 | view:split(true) 391 | buffer.new() 392 | buffer:append_text(test.lines{ 393 | '', -- 394 | 'modified more', -- 395 | 'added', -- 396 | 'unchanged' 397 | }) 398 | 399 | file_diff.start('-', '-') 400 | 401 | test.assert_equal(_VIEWS[1].buffer.annotation_text[2], ' ') 402 | end) 403 | 404 | test('file_diff should synchronize scrolling', function() 405 | local f1 = test.tmpfile(test.lines(100)) 406 | local f2 = test.tmpfile(test.lines(100)) 407 | 408 | file_diff.start(f1.filename, f2.filename) 409 | 410 | buffer:page_down() 411 | ui.update() -- trigger events.UPDATE_UI 412 | if CURSES then events.emit(events.UPDATE_UI, buffer.UPDATE_SELECTION) end 413 | 414 | test.assert_equal(_VIEWS[1].first_visible_line, _VIEWS[2].first_visible_line) 415 | end) 416 | if WIN32 and GUI then skip('crashes inside Scintilla') end -- TODO: 417 | 418 | test('file_diff should stop when switching buffers', function() 419 | start_basic_diff() 420 | 421 | view:goto_buffer(-1) 422 | 423 | local added_lines = test.get_marked_lines(file_diff.MARK_ADDITION, _VIEWS[1].buffer) 424 | test.assert_equal(added_lines, {}) 425 | end) 426 | 427 | -- Coverage tests. 428 | 429 | test('file_diff.goto_change should notify when there are no more changes', function() 430 | view:split(true) 431 | buffer.new() 432 | file_diff.start('-', '-') 433 | 434 | file_diff.goto_change(true) 435 | 436 | test.assert_equal(ui.statusbar_text, _L['No more differences']) 437 | end) 438 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | -- Copyright 2015-2025 Mitchell. See LICENSE. 2 | 3 | --- Two-way file comparison for Textadept. 4 | -- Install this module by copying it into your *~/.textadept/modules/* directory or Textadept's 5 | -- *modules/* directory, and then putting the following in your *~/.textadept/init.lua*: 6 | -- 7 | -- ```lua 8 | -- local file_diff = require('file_diff') 9 | -- ``` 10 | -- 11 | -- ## Compiling 12 | -- 13 | -- Releases include binaries, so building this modules should not be necessary. If you want 14 | -- to build manually, use CMake. For example: 15 | -- 16 | -- ```bash 17 | -- cmake -S . -B build_dir 18 | -- cmake --build build_dir --target diff 19 | -- cmake --install build_dir 20 | -- ``` 21 | -- 22 | -- ## Usage 23 | -- 24 | -- A sample workflow is this: 25 | -- 1. Start comparing two files via the "Compare Files" submenu in the "Tools" menu. 26 | -- 2. The caret is initially placed in the file on the left. 27 | -- 3. Go to the next change via menu or key binding. 28 | -- 4. Merge the change from the other buffer into the current one (right to left) via menu or 29 | -- key binding. 30 | -- 5. Go to the next change via menu or key binding. 31 | -- 6. Merge the change from the current buffer into the other one (left to right) via menu or 32 | -- key binding. 33 | -- 7. Repeat as necessary. 34 | -- 35 | -- Note: merging can be performed wherever the caret is placed when jumping between changes, 36 | -- even if one buffer has a change and the other does not (additions or deletions). 37 | -- 38 | -- ## Key Bindings 39 | -- 40 | -- Windows and Linux | macOS | Terminal | Command 41 | -- -|-|-|- 42 | -- **Tools**| | | 43 | -- F6 | F6 | None | Compare files... 44 | -- Shift+F6 | ⇧F6 | None | Compare the buffers in two split views 45 | -- Ctrl+F6 | ⌘F6 | None | Stop comparing 46 | -- Ctrl+Alt+. | ^⌘. | None | Goto next difference 47 | -- Ctrl+Alt+, | ^⌘, | None | Goto previous difference 48 | -- Ctrl+Alt+< | ^⌘< | None | Merge left 49 | -- Ctrl+Alt+> | ^⌘> | None | Merge right 50 | -- @module file_diff 51 | local M = {} 52 | 53 | --- The marker for line additions. 54 | M.MARK_ADDITION = view.new_marker_number() 55 | --- The marker for line deletions. 56 | M.MARK_DELETION = view.new_marker_number() 57 | --- The marker for line modifications. 58 | M.MARK_MODIFICATION = view.new_marker_number() 59 | --- The indicator number for text added within lines. 60 | M.INDIC_ADDITION = view.new_indic_number() 61 | --- The indicator number for text deleted within lines. 62 | M.INDIC_DELETION = view.new_indic_number() 63 | local MARK_ADDITION = M.MARK_ADDITION 64 | local MARK_DELETION = M.MARK_DELETION 65 | local MARK_MODIFICATION = M.MARK_MODIFICATION 66 | local INDIC_ADDITION = M.INDIC_ADDITION 67 | local INDIC_DELETION = M.INDIC_DELETION 68 | --- The name of the theme color used to mark additions. 69 | -- The default value is 'green'. If your theme does not define that color, set this field to 70 | -- your theme's equivalent. 71 | M.addition_color_name = 'green' 72 | --- The name of the theme color used to mark deletions. 73 | -- The default value is 'red'. If your theme does not define that color, set this field to your 74 | -- theme's equivalent. 75 | M.deletion_color_name = 'red' 76 | --- The name of the theme color used to mark modifications. 77 | -- The default value is 'yellow'. If your theme does not define that color, set this field to 78 | -- your theme's equivalent. 79 | M.modification_color_name = 'yellow' 80 | 81 | local lib = 'file_diff.diff' 82 | if OSX then 83 | lib = lib .. 'osx' 84 | elseif LINUX and io.popen('uname -m'):read() == 'aarch64' then 85 | lib = lib .. 'arm' 86 | end 87 | local diff = require(lib) 88 | local DELETE, INSERT = 0, 1 -- C++: "enum Operation {DELETE, INSERT, EQUAL};" 89 | 90 | local view1, view2 91 | 92 | --- Clear markers, indicators, and placeholder lines. 93 | -- Used when re-marking changes or finished comparing. 94 | local function clear_marked_changes() 95 | local buffer1 = _VIEWS[view1] and view1.buffer 96 | local buffer2 = _VIEWS[view2] and view2.buffer 97 | for _, mark in ipairs{MARK_ADDITION, MARK_DELETION, MARK_MODIFICATION} do 98 | if buffer1 then buffer1:marker_delete_all(mark) end 99 | if buffer2 then buffer2:marker_delete_all(mark) end 100 | end 101 | for _, indic in ipairs{INDIC_ADDITION, INDIC_DELETION} do 102 | if buffer1 then 103 | buffer1.indicator_current = indic 104 | buffer1:indicator_clear_range(1, buffer1.length) 105 | end 106 | if buffer2 then 107 | buffer2.indicator_current = indic 108 | buffer2:indicator_clear_range(1, buffer2.length) 109 | end 110 | end 111 | if buffer1 then buffer1:annotation_clear_all() end 112 | if buffer2 then buffer2:annotation_clear_all() end 113 | end 114 | 115 | local synchronizing = false 116 | --- Synchronize the scroll and line position of the other buffer. 117 | local function synchronize() 118 | synchronizing = true 119 | local line = buffer:line_from_position(buffer.current_pos) 120 | local visible_line = view:visible_from_doc_line(line) 121 | local first_visible_line = view.first_visible_line 122 | local x_offset = view.x_offset 123 | ui.goto_view(view == view1 and view2 or view1) 124 | buffer:goto_line(view:doc_line_from_visible(visible_line)) 125 | view.first_visible_line, view.x_offset = first_visible_line, x_offset 126 | ui.goto_view(view == view2 and view1 or view2) 127 | synchronizing = false 128 | end 129 | 130 | --- Returns the number of lines contained in the given string. 131 | local function count_lines(text) 132 | local lines = 1 133 | for _ in text:gmatch('\n') do lines = lines + 1 end 134 | return lines 135 | end 136 | 137 | --- Mark the differences between the two buffers. 138 | local function mark_changes() 139 | if not _VIEWS[view1] or not _VIEWS[view2] then return end 140 | clear_marked_changes() -- clear previous marks 141 | local buffer1, buffer2 = view1.buffer, view2.buffer 142 | -- Perform the diff. 143 | local diffs = diff(buffer1:get_text(), buffer2:get_text()) 144 | -- Parse the diff, marking modified lines and changed text. 145 | local pos1, pos2 = 1, 1 146 | for i = 1, #diffs, 2 do 147 | local op, text = diffs[i], diffs[i + 1] 148 | local text_len = #text 149 | if op == DELETE then 150 | local next_op, next_text = diffs[i + 2], diffs[i + 3] 151 | -- Mark partial lines as modified and full lines as deleted. 152 | local start_line = buffer1:line_from_position(pos1) 153 | local end_line = buffer1:line_from_position(pos1 + text_len) 154 | local mark = MARK_MODIFICATION -- assume partial initially 155 | if next_op ~= INSERT then 156 | -- Deleting full line(s), either from line start to next line(s) start, or from line 157 | -- end to next line(s) end. Adjust `start_line` and `end_line` accordingly for accurate 158 | -- line markers. 159 | if pos1 == buffer1:position_from_line(start_line) and 160 | (pos1 + text_len == buffer1:position_from_line(end_line)) then 161 | mark = MARK_DELETION 162 | end_line = end_line - 1 163 | elseif pos1 == buffer1.line_end_position[start_line] and 164 | (pos1 + text_len == buffer1.line_end_position[end_line]) then 165 | mark = MARK_DELETION 166 | start_line = start_line + 1 167 | end 168 | end 169 | for j = start_line, end_line do buffer1:marker_add(j, mark) end 170 | -- Highlight deletion from partially changed line(s) and mark other line as modified. 171 | if mark == MARK_MODIFICATION then 172 | buffer1.indicator_current = INDIC_DELETION 173 | buffer1:indicator_fill_range(pos1, text_len) 174 | buffer2:marker_add(buffer2:line_from_position(pos2), mark) 175 | end 176 | pos1 = pos1 + text_len 177 | -- Calculate net change in lines and fill in empty space in either buffer with annotations 178 | -- if necessary. 179 | if next_op == INSERT then 180 | -- Deleting line(s) in favor of other lines. If those other lines are more numerous, 181 | -- then fill empty space in this buffer. 182 | local num_lines = count_lines(text) 183 | local next_num_lines = count_lines(next_text) 184 | if num_lines < next_num_lines then 185 | local annotation_lines = next_num_lines - num_lines - 1 186 | local annotation_text = ' ' .. 187 | string.rep('\n', annotation_lines + buffer1.annotation_lines[end_line]) 188 | buffer1.annotation_text[end_line] = annotation_text 189 | end 190 | elseif mark == MARK_DELETION then 191 | -- Deleting full line(s) with no replacement, so fill empty space in other buffer. 192 | local offset = not text:find('^\n') and 1 or 0 193 | local line = math.max(buffer2:line_from_position(pos2) - offset, 1) 194 | local annotation_lines = end_line - start_line 195 | local annotation_text = ' ' .. string.rep('\n', annotation_lines) 196 | buffer2.annotation_text[line] = annotation_text 197 | else 198 | -- Deleting partial line(s) with no replacement, so fill empty space in other buffer. 199 | local extra_lines = count_lines(text) - 1 200 | if extra_lines > 0 then 201 | local line = buffer2:line_from_position(pos2) 202 | local annotation_lines = extra_lines - 1 203 | local annotation_text = ' ' .. string.rep('\n', annotation_lines) 204 | buffer2.annotation_text[line] = annotation_text 205 | end 206 | end 207 | elseif op == INSERT then 208 | local prev_op, prev_text = diffs[i - 2], diffs[i - 1] 209 | -- Mark partial lines as modified and full lines as deleted. 210 | local start_line = buffer2:line_from_position(pos2) 211 | local end_line = buffer2:line_from_position(pos2 + text_len) 212 | local mark = MARK_MODIFICATION -- assume partial initially 213 | if prev_op ~= DELETE then 214 | -- Adding full line(s), either from line start to next line(s) start, or from line end 215 | -- to next line(s) end. Adjust `start_line` and `end_line` accordingly for accurate 216 | -- line markers. 217 | if pos2 == buffer2:position_from_line(start_line) and 218 | (pos2 + text_len == buffer2:position_from_line(end_line)) then 219 | mark = MARK_ADDITION 220 | end_line = end_line - 1 221 | elseif pos2 == buffer2.line_end_position[start_line] and 222 | (pos2 + text_len == buffer2.line_end_position[end_line]) then 223 | mark = MARK_ADDITION 224 | start_line = start_line + 1 225 | end 226 | end 227 | for j = start_line, end_line do buffer2:marker_add(j, mark) end 228 | -- Highlight addition from partially changed line(s) and mark other line as modified. 229 | if mark == MARK_MODIFICATION then 230 | buffer2.indicator_current = INDIC_ADDITION 231 | buffer2:indicator_fill_range(pos2, text_len) 232 | buffer1:marker_add(buffer1:line_from_position(pos1), mark) 233 | end 234 | pos2 = pos2 + text_len 235 | -- Calculate net change in lines and fill in empty space in either buffer with annotations 236 | -- if necessary. 237 | if prev_op == DELETE then 238 | -- Adding line(s) in favor of other lines. If those other lines are more numerous, 239 | -- then fill empty space in this buffer. 240 | local num_lines = count_lines(text) 241 | local prev_num_lines = count_lines(prev_text) 242 | if num_lines < prev_num_lines then 243 | local annotation_lines = prev_num_lines - num_lines - 1 244 | local annotation_text = ' ' .. 245 | string.rep('\n', annotation_lines + buffer2.annotation_lines[end_line]) 246 | buffer2.annotation_text[end_line] = annotation_text 247 | end 248 | elseif mark == MARK_ADDITION then 249 | -- Adding full line(s) with no replacement, so fill empty space in other buffer. 250 | local offset = not text:find('^\n') and 1 or 0 251 | local line = math.max(buffer1:line_from_position(pos1) - offset, 1) 252 | local annotation_lines = end_line - start_line 253 | local annotation_text = ' ' .. string.rep('\n', annotation_lines) 254 | buffer1.annotation_text[line] = annotation_text 255 | else 256 | -- Adding partial line(s) with no replacement, so fill empty space in other buffer. 257 | local extra_lines = count_lines(text) - 1 258 | if extra_lines > 0 then 259 | local line = buffer1:line_from_position(pos1) 260 | local annotation_lines = extra_lines - 1 261 | local annotation_text = ' ' .. string.rep('\n', annotation_lines) 262 | buffer1.annotation_text[line] = annotation_text 263 | end 264 | end 265 | else 266 | pos1, pos2 = pos1 + text_len, pos2 + text_len 267 | end 268 | end 269 | synchronize() 270 | end 271 | 272 | local starting_diff = false 273 | 274 | --- Highlight differences between files. 275 | -- @param[opt] file1 String older filename. If `-`, uses the current buffer. If `nil`, the user 276 | -- is prompted for a file. 277 | -- @param[optchain] file2 String newer filename. If `-`, uses the current buffer. If `nil`, the user 278 | -- is prompted for a file. 279 | -- @param[optchain=false] horizontal Split the view horizontally instead of vertically. The 280 | -- default is to compare files side-by-side. 281 | function M.start(file1, file2, horizontal) 282 | file1 = file1 or ui.dialogs.open{ 283 | title = _L['Select the first file to compare'], 284 | dir = (buffer.filename or ''):match('^(.+)[/\\]') or lfs.currentdir() 285 | } 286 | if not file1 then return end 287 | file2 = file2 or ui.dialogs.open{ 288 | title = string.format('%s %s', _L['Select the file to compare to'], file1:match('[^/\\]+$')), 289 | dir = file1:match('^(.+)[/\\]') or lfs.currentdir() 290 | } 291 | if not file2 then return end 292 | starting_diff = true 293 | if not _VIEWS[view1] or not _VIEWS[view2] and #_VIEWS > 1 then 294 | view1, view2 = _VIEWS[1], _VIEWS[2] -- preserve current split views 295 | end 296 | if _VIEWS[view1] and view ~= view1 then ui.goto_view(view1) end 297 | if file1 ~= '-' then io.open_file(file1) end 298 | view.annotation_visible = view.ANNOTATION_STANDARD -- view1 299 | if not _VIEWS[view1] or not _VIEWS[view2] then 300 | view1, view2 = view:split(not horizontal) 301 | else 302 | ui.goto_view(view2) 303 | end 304 | if file2 ~= '-' then io.open_file(file2) end 305 | view.annotation_visible = view.ANNOTATION_STANDARD -- view2 306 | ui.goto_view(view1) 307 | starting_diff = false 308 | if file1 == '-' or file2 == '-' then mark_changes() end 309 | end 310 | 311 | --- Stops comparing. 312 | local function stop() 313 | if not _VIEWS[view1] or not _VIEWS[view2] then return end 314 | clear_marked_changes() 315 | view1, view2 = nil, nil 316 | end 317 | 318 | -- Stop comparing when one of the buffer's being compared is switched or closed. 319 | events.connect(events.BUFFER_BEFORE_SWITCH, function() if not starting_diff then stop() end end) 320 | events.connect(events.BUFFER_DELETED, stop) 321 | 322 | --- Retrieves a line number's equivalent in the other buffer. 323 | -- @param line Line to get the synchronized equivalent of in the other buffer. 324 | -- @return line 325 | local function get_synchronized_line(line) 326 | local visible_line = view:visible_from_doc_line(line) 327 | local pos = buffer.current_pos 328 | ui.goto_view(view == view1 and view2 or view1) 329 | line = view:doc_line_from_visible(visible_line) 330 | ui.goto_view(view == view2 and view1 or view2) 331 | buffer:set_empty_selection(pos) 332 | return line 333 | end 334 | 335 | --- Jumps to the next or previous difference between the two files. 336 | -- `file_diff.start()` must have been called previously. 337 | -- @param[opt=false] next Go to the next previous difference relative to the current line, 338 | -- as opposed to the previous one. 339 | function M.goto_change(next) 340 | if not _VIEWS[view1] or not _VIEWS[view2] then return end 341 | -- Determine the line to start on, keeping in mind the synchronized line numbers may be different. 342 | local line1, line2 343 | local step = next and 1 or -1 344 | if view == view1 then 345 | line1 = buffer:line_from_position(buffer.current_pos) + step 346 | if line1 < 1 then line1 = 1 end 347 | line2 = get_synchronized_line(line1) 348 | else 349 | line2 = buffer:line_from_position(buffer.current_pos) + step 350 | if line2 < 1 then line2 = 1 end 351 | line1 = get_synchronized_line(line2) 352 | end 353 | -- Search for the next change or set of changes, wrapping as necessary. 354 | -- A block of additions, deletions, or modifications should be treated as a single change. 355 | local buffer1, buffer2 = view1.buffer, view2.buffer 356 | local diff_marker = 1 << MARK_ADDITION - 1 | 1 << MARK_DELETION - 1 | 1 << MARK_MODIFICATION - 1 357 | local f = next and buffer.marker_next or buffer.marker_previous 358 | line1 = f(buffer1, line1, diff_marker) 359 | while line1 >= 1 and 360 | (buffer1:marker_get(line1) & diff_marker == buffer1:marker_get(line1 - step) & diff_marker) do 361 | line1 = f(buffer1, line1 + step, diff_marker) 362 | end 363 | line2 = f(buffer2, line2, diff_marker) 364 | while line2 >= 1 and 365 | (buffer2:marker_get(line2) & diff_marker == buffer2:marker_get(line2 - step) & diff_marker) do 366 | line2 = f(buffer2, line2 + step, diff_marker) 367 | end 368 | if line1 < 1 and line2 < 1 then 369 | line1 = f(buffer1, next and 1 or buffer1.line_count, diff_marker) 370 | line2 = f(buffer2, next and 1 or buffer2.line_count, diff_marker) 371 | end 372 | if line1 < 1 and line2 < 1 then 373 | ui.statusbar_text = _L['No more differences'] 374 | return 375 | end 376 | -- Determine which change is closer to the current line, keeping in mind the synchronized 377 | -- line numbers may be different. (For example, one buffer may have a block of modifications 378 | -- next while the other buffer has a block of additions next, and those additions logically 379 | -- come first.) 380 | if view == view1 then 381 | if line2 >= 1 then 382 | ui.goto_view(view2) 383 | local visible_line = view:visible_from_doc_line(line2) 384 | ui.goto_view(view1) 385 | local line2_1 = view:doc_line_from_visible(visible_line) 386 | buffer:goto_line(line1 >= 1 and (next and line1 < line2_1 or not next and line1 > line2_1) and 387 | line1 or line2_1) 388 | else 389 | buffer:goto_line(line1) 390 | end 391 | else 392 | if line1 >= 1 then 393 | ui.goto_view(view1) 394 | local visible_line = view:visible_from_doc_line(line1) 395 | ui.goto_view(view2) 396 | local line1_2 = view:doc_line_from_visible(visible_line) 397 | buffer:goto_line(line2 >= 1 and (next and line2 < line1_2 or not next and line2 > line1_2) and 398 | line2 or line1_2) 399 | else 400 | buffer:goto_line(line2) 401 | end 402 | end 403 | view:vertical_center_caret() 404 | end 405 | 406 | --- Merges a change from one buffer to another, depending on the change under the caret and the 407 | -- merge direction. 408 | -- @param[opt=false] left Merge from right to left as opposed to left to right. 409 | function M.merge(left) 410 | if not _VIEWS[view1] or not _VIEWS[view2] then return end 411 | local buffer1, buffer2 = view1.buffer, view2.buffer 412 | -- Determine whether or not there is a change to merge. 413 | local start_line = buffer:line_from_position(buffer.current_pos) 414 | local end_line = start_line + 1 415 | local diff_marker = 1 << MARK_ADDITION - 1 | 1 << MARK_DELETION - 1 | 1 << MARK_MODIFICATION - 1 416 | local marker = buffer:marker_get(start_line) & diff_marker 417 | if marker == 0 then 418 | -- Look for additions or deletions from the other buffer, which are offset one line down 419 | -- (side-effect of Scintilla's visible line -> doc line conversions). 420 | local line = get_synchronized_line(start_line) + 1 421 | if (view == view1 and buffer2 or buffer1):marker_get(line) & diff_marker > 0 then 422 | ui.goto_view(view == view1 and view2 or view1) 423 | buffer:set_empty_selection(buffer:position_from_line(line)) 424 | M.merge(left) 425 | ui.goto_view(view == view2 and view1 or view2) 426 | buffer:set_empty_selection(buffer:position_from_line(start_line)) 427 | end 428 | return 429 | end 430 | -- Determine the bounds of the change target it. 431 | while buffer:marker_get(start_line - 1) & diff_marker == marker do start_line = start_line - 1 end 432 | buffer.target_start = buffer:position_from_line(start_line) 433 | while buffer:marker_get(end_line) & diff_marker == marker do end_line = end_line + 1 end 434 | buffer.target_end = buffer:position_from_line(end_line) 435 | -- Perform the merge, depending on context. 436 | if marker == 1 << MARK_ADDITION - 1 then 437 | if left then 438 | -- Merge addition from right to left. 439 | local line = get_synchronized_line(end_line) 440 | buffer1:insert_text(buffer1:position_from_line(line), buffer2.target_text) 441 | else 442 | -- Merge "deletion" (empty text) from left to right. 443 | buffer2:replace_target('') 444 | end 445 | elseif marker == 1 << MARK_DELETION - 1 then 446 | if left then 447 | -- Merge "addition" (empty text) from right to left. 448 | buffer1:replace_target('') 449 | else 450 | -- Merge deletion from left to right. 451 | local line = get_synchronized_line(end_line) 452 | buffer2:insert_text(buffer2:position_from_line(line), buffer1.target_text) 453 | end 454 | elseif marker == 1 << MARK_MODIFICATION - 1 then 455 | local target_text = buffer.target_text 456 | start_line = get_synchronized_line(start_line) 457 | end_line = get_synchronized_line(end_line) 458 | ui.goto_view(view == view1 and view2 or view1) 459 | buffer.target_start = buffer:position_from_line(start_line) 460 | buffer.target_end = buffer:position_from_line(end_line) 461 | if view == view2 and left or view == view1 and not left then 462 | -- Merge change from opposite view. 463 | target_text = buffer.target_text 464 | ui.goto_view(view == view2 and view1 or view2) 465 | buffer:replace_target(target_text) 466 | else 467 | -- Merge change to opposite view. 468 | buffer:replace_target(target_text) 469 | ui.goto_view(view == view2 and view1 or view2) 470 | end 471 | end 472 | mark_changes() -- refresh 473 | end 474 | 475 | -- TODO: connect to these in `start()` and disconnect in `stop()`? 476 | 477 | -- Ensure the diff buffers are scrolled in sync. 478 | events.connect(events.UPDATE_UI, function(updated) 479 | if _VIEWS[view1] and _VIEWS[view2] and updated and not synchronizing then 480 | if updated & (view.UPDATE_H_SCROLL | view.UPDATE_V_SCROLL | buffer.UPDATE_SELECTION) > 0 then 481 | synchronize() 482 | end 483 | end 484 | end) 485 | 486 | -- Highlight differences as text is typed and deleted. 487 | events.connect(events.MODIFIED, function(_, modification_type) 488 | if not _VIEWS[view1] or not _VIEWS[view2] then return end 489 | if modification_type & (0x01 | 0x02) > 0 then mark_changes() end -- insert text | delete text 490 | end) 491 | 492 | events.connect(events.VIEW_NEW, function() 493 | local markers = { 494 | [MARK_ADDITION] = M.addition_color_name, [MARK_DELETION] = M.deletion_color_name, 495 | [MARK_MODIFICATION] = M.modification_color_name 496 | } 497 | for mark, color in pairs(markers) do 498 | view:marker_define(mark, not CURSES and view.MARK_BACKGROUND or view.MARK_FULLRECT) 499 | if view.colors[color] then view.marker_back[mark] = view.colors[color] end 500 | if not CURSES then 501 | view.marker_layer[mark], view.marker_alpha[mark] = view.LAYER_UNDER_TEXT, 0x60 502 | end 503 | end 504 | local indicators = { 505 | [INDIC_ADDITION] = M.addition_color_name, [INDIC_DELETION] = M.deletion_color_name 506 | } 507 | for indic, color in pairs(indicators) do 508 | view.indic_style[indic] = not CURSES and view.INDIC_FULLBOX or view.INDIC_STRAIGHTBOX 509 | if view.colors[color] then view.indic_fore[indic] = view.colors[color] end 510 | if not CURSES then view.indic_alpha[indic], view.indic_under[indic] = 0x60, true end 511 | end 512 | end) 513 | 514 | args.register('-d', '--diff', 2, M.start, 'Compares two files') 515 | 516 | -- Add a menu and configure key bindings. 517 | -- (Insert 'Compare Files' menu in alphabetical order.) 518 | _L['Compare Files'] = 'Compare _Files' 519 | _L['Compare Files...'] = '_Compare Files...' 520 | _L['Compare This File With...'] = 'Compare This File _With...' 521 | _L['Compare Buffers'] = 'Compare _Buffers' 522 | _L['Next Change'] = '_Next Change' 523 | _L['Previous Change'] = '_Previous Change' 524 | _L['Merge Left'] = 'Merge _Left' 525 | _L['Merge Right'] = 'Merge _Right' 526 | _L['Stop Comparing'] = '_Stop Comparing' 527 | local m_tools = textadept.menu.menubar['Tools'] 528 | local found_area 529 | for i = 1, #m_tools - 1 do 530 | if not found_area and m_tools[i + 1].title == _L['Bookmarks'] then 531 | found_area = true 532 | elseif found_area then 533 | local label = m_tools[i].title or m_tools[i][1] 534 | if 'Compare Files' < label:gsub('^_', '') or m_tools[i][1] == '' then 535 | table.insert(m_tools, i, { 536 | title = _L['Compare Files'], -- 537 | {_L['Compare Files...'], M.start}, { 538 | _L['Compare This File With...'], 539 | function() if buffer.filename then M.start(buffer.filename) end end 540 | }, {_L['Compare Buffers'], function() M.start('-', '-') end}, -- 541 | {''}, -- 542 | {_L['Next Change'], function() M.goto_change(true) end}, 543 | {_L['Previous Change'], M.goto_change}, -- 544 | {''}, -- 545 | {_L['Merge Left'], function() M.merge(true) end}, -- 546 | {_L['Merge Right'], M.merge}, -- 547 | {''}, -- 548 | {_L['Stop Comparing'], stop} 549 | }) 550 | break 551 | end 552 | end 553 | end 554 | 555 | keys.assign_platform_bindings{ 556 | [M.start] = {'f6', 'f6', nil}, 557 | [m_tools[_L['Compare Files']][_L['Compare Buffers']][2]] = {'shift+f6', 'shift+f6', nil}, 558 | [m_tools[_L['Compare Files']][_L['Stop Comparing']][2]] = {'ctrl+f6', 'cmd+f6', nil}, 559 | [m_tools[_L['Compare Files']][_L['Next Change']][2]] = {'ctrl+alt+.', 'ctrl+cmd+.', nil}, 560 | [M.goto_change] = {'ctrl+alt+,', 'ctrl+cmd+,', nil}, 561 | [m_tools[_L['Compare Files']][_L['Merge Left']][2]] = {'ctrl+alt+<', 'ctrl+cmd+<', nil}, 562 | [M.merge] = {'ctrl+alt+>', 'ctrl+cmd+>', nil} 563 | } 564 | 565 | return M 566 | 567 | -- The function below is a Lua C function. 568 | 569 | --- Returns a list of the differences between strings. 570 | -- Each consecutive pair of elements in the returned list represents a "diff". The first element 571 | -- is an integer: 0 for a deletion, 1 for an insertion, and 2 for equality. The second element 572 | -- is the associated diff text. 573 | -- @param text1 String to compare against. 574 | -- @param text2 String to compare. 575 | -- @usage diffs = diff(text1, text2) 576 | -- @usage for i = 1, #diffs, 2 do print(diffs[i], diffs[i + 1]) end 577 | -- @function _G.diff 578 | --------------------------------------------------------------------------------