diff --git a/hgext/fix.py b/hgext/fix.py --- a/hgext/fix.py +++ b/hgext/fix.py @@ -45,6 +45,23 @@ tool needs to operate on unchanged files, it should set the :skipclean suboption to false. +By default, delete-only diffs will not emit line ranges to be fixed (after all, +they no longer exist in the file). However it may be useful to some fixers to +format lines around deletions. For instance, you may want to remove excess +whitespace created by deleting a block. Fixers may be configured to receive +deletion-adjacent lines by setting :emitdeletedranges. The supported modes are +as follows:: + + "none" + No deletion-adjacent lines will be emitted. + + "adjacentblanks" + Only deletion-adjacent lines that consist exclusively of ASCII whitespace + will be emitted. + + "adjacent" + All deletion-adjacent lines will be emitted. + The :pattern suboption determines which files will be passed through each configured tool. See :hg:`help patterns` for possible values. However, all patterns are relative to the repo root, even if that text says they are relative @@ -173,6 +190,7 @@ b'priority': 0, b'metadata': False, b'skipclean': True, + b'emitdeletedranges': b'none', b'enabled': True, } @@ -479,7 +497,7 @@ return files -def lineranges(opts, path, basepaths, basectxs, fixctx, content2): +def lineranges(opts, fixer, path, basepaths, basectxs, fixctx, content2): """Returns the set of line ranges that should be fixed in a file Of the form [(10, 20), (30, 40)]. @@ -494,7 +512,7 @@ if opts.get(b'whole'): # Return a range containing all lines. Rely on the diff implementation's # idea of how many lines are in the file, instead of reimplementing it. - return difflineranges(b'', content2) + return difflineranges(fixer, b'', content2) rangeslist = [] for basectx in basectxs: @@ -504,7 +522,7 @@ content1 = basectx[basepath].data() else: content1 = b'' - rangeslist.extend(difflineranges(content1, content2)) + rangeslist.extend(difflineranges(fixer, content1, content2)) return unionranges(rangeslist) @@ -558,18 +576,17 @@ return unioned -def difflineranges(content1, content2): +def difflineranges(fixer, content1, content2): """Return list of line number ranges in content2 that differ from content1. Line numbers are 1-based. The numbers are the first and last line contained in the range. Single-line ranges have the same line number for the first and - last line. Excludes any empty ranges that result from lines that are only - present in content1. Relies on mdiff's idea of where the line endings are in - the string. + last line. Relies on mdiff's idea of where the line endings are in the + string. >>> from mercurial import pycompat >>> lines = lambda s: b'\\n'.join([c for c in pycompat.iterbytestr(s)]) - >>> difflineranges2 = lambda a, b: difflineranges(lines(a), lines(b)) + >>> difflineranges2 = lambda a, b: difflineranges(..., lines(a), lines(b)) >>> difflineranges2(b'', b'') [] >>> difflineranges2(b'a', b'') @@ -598,10 +615,41 @@ [(2, 4)] """ ranges = [] + alladjacent = fixer.emitdeletionadjacent() + onlyblanks = fixer.emitdeletionadjacentblanks() + blanks2 = () + if alladjacent or onlyblanks: + blanks2 = tuple( + line.isspace() for line in mdiff.splitnewlines(content2)) for lines, kind in mdiff.allblocks(content1, content2): firstline, lastline = lines[2:4] - if kind == b'!' and firstline != lastline: - ranges.append((firstline + 1, lastline)) + # Skip all blocks where the content is equivalent. + ischange = (kind == b'!') + if not ischange: + continue + # Unconditionally produce a line range for additions and modifications. + # Only produce one for deletions when :emitdeletedranges is not 'none'. + isdeletion = (firstline == lastline) + if not isdeletion: + newrange = (firstline + 1, lastline) + elif alladjacent: + newrange = ( + max(firstline, 1), + min(lastline + 1, len(blanks2)), + ) + elif onlyblanks and any(blanks2[firstline - 1:lastline + 1]): + newrange = ( + firstline + 1 - int(any(blanks2[firstline - 1:firstline])), + lastline + int(any(blanks2[lastline:lastline + 1])), + ) + else: + continue + # Combine with previous range if new one is adjacent or overlaps. + if ranges and ranges[-1][1] >= newrange[0] - 1: + lowerbound, _ = ranges.pop() + ranges.append((lowerbound, newrange[1])) + else: + ranges.append(newrange) return ranges @@ -675,7 +723,7 @@ for fixername, fixer in pycompat.iteritems(fixers): if fixer.affects(opts, fixctx, path): ranges = lineranges( - opts, path, basepaths, basectxs, fixctx, newdata + opts, fixer, path, basepaths, basectxs, fixctx, newdata ) command = fixer.command(ui, path, ranges) if command is None: @@ -847,6 +895,7 @@ priority = ui.configint(b'fix', name + b':priority') metadata = ui.configbool(b'fix', name + b':metadata') skipclean = ui.configbool(b'fix', name + b':skipclean') + deletedranges = ui.config(b'fix', name + b':emitdeletedranges') # Don't use a fixer if it has no pattern configured. It would be # dangerous to let it affect all files. It would be pointless to let it # affect no files. There is no reasonable subset of files to use as the @@ -863,7 +912,8 @@ ui.debug(b'ignoring disabled fixer tool: %s\n' % (name,)) else: fixers[name] = Fixer( - command, pattern, linerange, priority, metadata, skipclean + command, pattern, linerange, priority, metadata, skipclean, + deletedranges ) return collections.OrderedDict( sorted(fixers.items(), key=lambda item: item[1]._priority, reverse=True) @@ -883,7 +933,8 @@ """Wraps the raw config values for a fixer with methods""" def __init__( - self, command, pattern, linerange, priority, metadata, skipclean + self, command, pattern, linerange, priority, metadata, skipclean, + deletedranges ): self._command = command self._pattern = pattern @@ -891,6 +942,7 @@ self._priority = priority self._metadata = metadata self._skipclean = skipclean + self._deletedranges = deletedranges def affects(self, opts, fixctx, path): """Should this fixer run on the file at the given path and context?""" @@ -904,6 +956,14 @@ """Should the stdout of this fixer start with JSON and a null byte?""" return self._metadata + def emitdeletionadjacent(self): + """Should this fixer receive ranges of lines adjacent to deletions?""" + return self._deletedranges == b'adjacent' + + def emitdeletionadjacentblanks(self): + """Should this fixer receive ranges of blanks adjacent to deletions?""" + return self._deletedranges == b'adjacentblanks' + def command(self, ui, path, ranges): """A shell command to use to invoke this fixer on the given file/lines diff --git a/tests/test-fix.t b/tests/test-fix.t --- a/tests/test-fix.t +++ b/tests/test-fix.t @@ -156,6 +156,23 @@ If such a tool needs to operate on unchanged files, it should set the :skipclean suboption to false. + By default, delete-only diffs will not emit line ranges to be fixed (after + all, they no longer exist in the file). However it may be useful to some + fixers to format lines around deletions. For instance, you may want to remove + excess whitespace created by deleting a block. Fixers may be configured to + receive deletion-adjacent lines by setting :emitdeletedranges. The supported + modes are as follows: + + "none" + No deletion-adjacent lines will be emitted. + + "adjacentblanks" + Only deletion-adjacent lines that consist exclusively of ASCII whitespace + will be emitted. + + "adjacent" + All deletion-adjacent lines will be emitted. + The :pattern suboption determines which files will be passed through each configured tool. See 'hg help patterns' for possible values. However, all patterns are relative to the repo root, even if that text says they are @@ -1453,6 +1470,80 @@ $ cd .. +Tools should be able to include deletion-adjacent blanks in generated line +ranges when set :emitdeletedranges is set to 'adjacentblanks'. + + $ hg init deletionadjacentblanks + $ cd deletionadjacentblanks + + $ printf "a\nb\nc\n" > neither + $ printf "a\n\t\nb\n \nc\n" > both + $ printf "a\n\nb\nc\n" > above + $ printf "a\nb \n\nc\n" > below + $ hg commit -Aqm "base" + + $ printf "a\nc\n" > neither + $ printf "a\n\n\nc\n" > both + $ printf "a\n\nc\n" > above + $ printf "a\n\nc\n" > below + + $ hg fix --working-dir neither both above below \ + > --config "fix.downwithwhitespace:command=printf \"%s\n\"" \ + > --config 'fix.downwithwhitespace:linerange="{first} through {last}"' \ + > --config 'fix.downwithwhitespace:pattern=glob:**' \ + > --config 'fix.downwithwhitespace:skipclean=false' \ + > --config 'fix.downwithwhitespace:emitdeletedranges=adjacentblanks' + + $ cat neither + + $ cat both + 2 through 3 + $ cat above + 2 through 2 + $ cat below + 2 through 2 + + $ cd .. + +Tools should be able to include all deletion-adjacent lines in generated line ranges when +set :emitdeletedranges is set to 'adjacent'. + + $ hg init deletionadjacent + $ cd deletionadjacent + + $ printf "a\nb\nc\n" > one + $ printf "a\nfoo\nb\nbar\nc\n" > two + $ printf "a\n\nb\nc\n" > three + $ printf "a\nb\nc\n" > validbounds + $ printf "a\nb\nc\nd\ne\n" > collapseadjacentrange + $ hg commit -Aqm "base" + + $ printf "a\nc\n" > one + $ printf "a\nfoo\nbar\nc\n" > two + $ printf "a\n\nc\n" > three + $ printf "b\n" > validbounds + $ printf "b\nc\ne\n" > collapseadjacentrange + + $ hg fix --working-dir one two three validbounds collapseadjacentrange \ + > --config "fix.cleanupadjacent:command=printf \"%s\n\"" \ + > --config 'fix.cleanupadjacent:linerange="{first} through {last}"' \ + > --config 'fix.cleanupadjacent:pattern=glob:**' \ + > --config 'fix.cleanupadjacent:skipclean=false' \ + > --config 'fix.cleanupadjacent:emitdeletedranges=adjacent' + + $ cat one + 1 through 2 + $ cat two + 2 through 3 + $ cat three + 2 through 3 + $ cat validbounds + 1 through 1 + $ cat collapseadjacentrange + 1 through 3 + + $ cd .. + Test various cases around merges. We were previously dropping files if they were created on only the p2 side of the merge, so let's test permutations of: * added, was fixed