ydiff.py 32KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. Term based tool to view *colored*, *incremental* diff in a *Git/Mercurial/Svn*
  5. workspace or from stdin, with *side by side* and *auto pager* support. Requires
  6. python (>= 2.5.0) and ``less``.
  7. """
  8. import sys
  9. import os
  10. import re
  11. import signal
  12. import subprocess
  13. import select
  14. import difflib
  15. META_INFO = {
  16. 'version' : '1.1',
  17. 'license' : 'BSD-3',
  18. 'author' : 'Matthew Wang',
  19. 'email' : 'mattwyl(@)gmail(.)com',
  20. 'url' : 'https://github.com/ymattw/ydiff',
  21. 'keywords' : 'colored incremental side-by-side diff',
  22. 'description' : ('View colored, incremental diff in a workspace or from '
  23. 'stdin, with side by side and auto pager support')
  24. }
  25. if sys.hexversion < 0x02050000:
  26. raise SystemExit('*** Requires python >= 2.5.0') # pragma: no cover
  27. # Python < 2.6 does not have next()
  28. try:
  29. next
  30. except NameError:
  31. def next(obj):
  32. return obj.next()
  33. try:
  34. unicode
  35. except NameError:
  36. unicode = str
  37. COLORS = {
  38. 'reset' : '\x1b[0m',
  39. 'underline' : '\x1b[4m',
  40. 'reverse' : '\x1b[7m',
  41. 'red' : '\x1b[31m',
  42. 'green' : '\x1b[32m',
  43. 'yellow' : '\x1b[33m',
  44. 'blue' : '\x1b[34m',
  45. 'magenta' : '\x1b[35m',
  46. 'cyan' : '\x1b[36m',
  47. 'lightred' : '\x1b[1;31m',
  48. 'lightgreen' : '\x1b[1;32m',
  49. 'lightyellow' : '\x1b[1;33m',
  50. 'lightblue' : '\x1b[1;34m',
  51. 'lightmagenta' : '\x1b[1;35m',
  52. 'lightcyan' : '\x1b[1;36m',
  53. }
  54. # Keys for revision control probe, diff and log with diff
  55. VCS_INFO = {
  56. 'Git': {
  57. 'probe': ['git', 'rev-parse'],
  58. 'diff': ['git', 'diff', '--no-ext-diff'],
  59. 'log': ['git', 'log', '--patch'],
  60. },
  61. 'Mercurial': {
  62. 'probe': ['hg', 'summary'],
  63. 'diff': ['hg', 'diff'],
  64. 'log': ['hg', 'log', '--patch'],
  65. },
  66. 'Svn': {
  67. 'probe': ['svn', 'info'],
  68. 'diff': ['svn', 'diff'],
  69. 'log': ['svn', 'log', '--diff', '--use-merge-history'],
  70. },
  71. }
  72. def colorize(text, start_color, end_color='reset'):
  73. return COLORS[start_color] + text + COLORS[end_color]
  74. def strsplit(text, width):
  75. r"""strsplit() splits a given string into two substrings, respecting the
  76. escape sequences (in a global var COLORS).
  77. It returns 3-tuple: (first string, second string, number of visible chars
  78. in the first string).
  79. If some color was active at the splitting point, then the first string is
  80. appended with the resetting sequence, and the second string is prefixed
  81. with all active colors.
  82. """
  83. first = ''
  84. second = ''
  85. found_colors = []
  86. chars_cnt = 0
  87. bytes_cnt = 0
  88. while text:
  89. # First of all, check if current string begins with any escape
  90. # sequence.
  91. append_len = 0
  92. for color in COLORS:
  93. if text.startswith(COLORS[color]):
  94. if color == 'reset':
  95. found_colors = []
  96. else:
  97. found_colors.append(color)
  98. append_len = len(COLORS[color])
  99. break
  100. if append_len == 0:
  101. # Current string does not start with any escape sequence, so,
  102. # either add one more visible char to the "first" string, or
  103. # break if that string is already large enough.
  104. if chars_cnt >= width:
  105. break
  106. chars_cnt += 1
  107. append_len = 1
  108. first += text[:append_len]
  109. text = text[append_len:]
  110. bytes_cnt += append_len
  111. second = text
  112. # If the first string has some active colors at the splitting point,
  113. # reset it and append the same colors to the second string
  114. if found_colors:
  115. first += COLORS['reset']
  116. for color in found_colors:
  117. second = COLORS[color] + second
  118. return (first, second, chars_cnt)
  119. def strtrim(text, width, wrap_char, pad):
  120. r"""strtrim() trims given string respecting the escape sequences (using
  121. strsplit), so that if text is larger than width, it's trimmed to have
  122. width-1 chars plus wrap_char. Additionally, if pad is True, short strings
  123. are padded with space to have exactly needed width.
  124. Returns resulting string.
  125. """
  126. text, _, tlen = strsplit(text, width + 1)
  127. if tlen > width:
  128. text, _, _ = strsplit(text, width - 1)
  129. text += wrap_char
  130. elif pad:
  131. # The string is short enough, but it might need to be padded.
  132. text = '%s%*s' % (text, width - tlen, '')
  133. return text
  134. class Hunk(object):
  135. def __init__(self, hunk_headers, hunk_meta, old_addr, new_addr):
  136. self._hunk_headers = hunk_headers
  137. self._hunk_meta = hunk_meta
  138. self._old_addr = old_addr # tuple (start, offset)
  139. self._new_addr = new_addr # tuple (start, offset)
  140. self._hunk_list = [] # list of tuple (attr, line)
  141. def append(self, hunk_line):
  142. """hunk_line is a 2-element tuple: (attr, text), where attr is:
  143. '-': old, '+': new, ' ': common
  144. """
  145. self._hunk_list.append(hunk_line)
  146. def mdiff(self):
  147. r"""The difflib._mdiff() function returns an interator which returns a
  148. tuple: (from line tuple, to line tuple, boolean flag)
  149. from/to line tuple -- (line num, line text)
  150. line num -- integer or None (to indicate a context separation)
  151. line text -- original line text with following markers inserted:
  152. '\0+' -- marks start of added text
  153. '\0-' -- marks start of deleted text
  154. '\0^' -- marks start of changed text
  155. '\1' -- marks end of added/deleted/changed text
  156. boolean flag -- None indicates context separation, True indicates
  157. either "from" or "to" line contains a change, otherwise False.
  158. """
  159. return difflib._mdiff(self._get_old_text(), self._get_new_text())
  160. def _get_old_text(self):
  161. return [line for (attr, line) in self._hunk_list if attr != '+']
  162. def _get_new_text(self):
  163. return [line for (attr, line) in self._hunk_list if attr != '-']
  164. def is_completed(self):
  165. old_completed = self._old_addr[1] == len(self._get_old_text())
  166. new_completed = self._new_addr[1] == len(self._get_new_text())
  167. return old_completed and new_completed
  168. class UnifiedDiff(object):
  169. def __init__(self, headers, old_path, new_path, hunks):
  170. self._headers = headers
  171. self._old_path = old_path
  172. self._new_path = new_path
  173. self._hunks = hunks
  174. def is_old_path(self, line):
  175. return line.startswith('--- ')
  176. def is_new_path(self, line):
  177. return line.startswith('+++ ')
  178. def is_hunk_meta(self, line):
  179. """Minimal valid hunk meta is like '@@ -1 +1 @@', note extra chars
  180. might occur after the ending @@, e.g. in git log. '## ' usually
  181. indicates svn property changes in output from `svn log --diff`
  182. """
  183. return (line.startswith('@@ -') and line.find(' @@') >= 8 or
  184. line.startswith('## -') and line.find(' ##') >= 8)
  185. def parse_hunk_meta(self, hunk_meta):
  186. # @@ -3,7 +3,6 @@
  187. a = hunk_meta.split()[1].split(',') # -3 7
  188. if len(a) > 1:
  189. old_addr = (int(a[0][1:]), int(a[1]))
  190. else:
  191. # @@ -1 +1,2 @@
  192. old_addr = (int(a[0][1:]), 1)
  193. b = hunk_meta.split()[2].split(',') # +3 6
  194. if len(b) > 1:
  195. new_addr = (int(b[0][1:]), int(b[1]))
  196. else:
  197. # @@ -0,0 +1 @@
  198. new_addr = (int(b[0][1:]), 1)
  199. return (old_addr, new_addr)
  200. def parse_hunk_line(self, line):
  201. return (line[0], line[1:])
  202. def is_old(self, line):
  203. """Exclude old path and header line from svn log --diff output, allow
  204. '----' likely to see in diff from yaml file
  205. """
  206. return (line.startswith('-') and not self.is_old_path(line) and
  207. not re.match(r'^-{72}$', line.rstrip()))
  208. def is_new(self, line):
  209. return line.startswith('+') and not self.is_new_path(line)
  210. def is_common(self, line):
  211. return line.startswith(' ')
  212. def is_eof(self, line):
  213. # \ No newline at end of file
  214. # \ No newline at end of property
  215. return line.startswith(r'\ No newline at end of')
  216. def is_only_in_dir(self, line):
  217. return line.startswith('Only in ')
  218. def is_binary_differ(self, line):
  219. return re.match('^Binary files .* differ$', line.rstrip())
  220. class PatchStream(object):
  221. def __init__(self, diff_hdl):
  222. self._diff_hdl = diff_hdl
  223. self._stream_header_size = 0
  224. self._stream_header = []
  225. # Test whether stream is empty by read 1 line
  226. line = self._diff_hdl.readline()
  227. if not line:
  228. self._is_empty = True
  229. else:
  230. self._stream_header.append(line)
  231. self._stream_header_size += 1
  232. self._is_empty = False
  233. def is_empty(self):
  234. return self._is_empty
  235. def read_stream_header(self, stream_header_size):
  236. """Returns a small chunk for patch type detect, suppose to call once"""
  237. for i in range(1, stream_header_size):
  238. line = self._diff_hdl.readline()
  239. if not line:
  240. break
  241. self._stream_header.append(line)
  242. self._stream_header_size += 1
  243. return self._stream_header
  244. def __iter__(self):
  245. for line in self._stream_header:
  246. yield line
  247. for line in self._diff_hdl:
  248. yield line
  249. class PatchStreamForwarder(object):
  250. """A blocking stream forwarder use `select` and line buffered mode. Feed
  251. input stream to a diff format translator and read output stream from it.
  252. Note input stream is non-seekable, and upstream has eaten some lines.
  253. """
  254. def __init__(self, istream, translator):
  255. assert isinstance(istream, PatchStream)
  256. assert isinstance(translator, subprocess.Popen)
  257. self._istream = iter(istream)
  258. self._in = translator.stdin
  259. self._out = translator.stdout
  260. def _can_read(self, timeout=0):
  261. return select.select([self._out.fileno()], [], [], timeout)[0]
  262. def _forward_line(self):
  263. try:
  264. line = next(self._istream)
  265. self._in.write(line)
  266. except StopIteration:
  267. self._in.close()
  268. def __iter__(self):
  269. while True:
  270. if self._can_read():
  271. line = self._out.readline()
  272. if line:
  273. yield line
  274. else:
  275. return
  276. elif not self._in.closed:
  277. self._forward_line()
  278. class DiffParser(object):
  279. def __init__(self, stream):
  280. header = [decode(line) for line in stream.read_stream_header(100)]
  281. size = len(header)
  282. if size >= 4 and (header[0].startswith('*** ') and
  283. header[1].startswith('--- ') and
  284. header[2].rstrip() == '***************' and
  285. header[3].startswith('*** ') and
  286. header[3].rstrip().endswith(' ****')):
  287. # For context diff, try use `filterdiff` to translate it to unified
  288. # format and provide a new stream
  289. #
  290. self._type = 'context'
  291. try:
  292. # Use line buffered mode so that to readline() in block mode
  293. self._translator = subprocess.Popen(
  294. ['filterdiff', '--format=unified'], stdin=subprocess.PIPE,
  295. stdout=subprocess.PIPE, bufsize=1)
  296. except OSError:
  297. raise SystemExit('*** Context diff support depends on '
  298. 'filterdiff')
  299. self._stream = PatchStreamForwarder(stream, self._translator)
  300. return
  301. for n in range(size):
  302. if (header[n].startswith('--- ') and (n < size - 1) and
  303. header[n + 1].startswith('+++ ')):
  304. self._type = 'unified'
  305. self._stream = stream
  306. break
  307. else:
  308. # `filterdiff` translates unknown diff to nothing, fall through to
  309. # unified diff give ydiff a chance to show everything as headers
  310. #
  311. sys.stderr.write("*** unknown format, fall through to 'unified'\n")
  312. self._type = 'unified'
  313. self._stream = stream
  314. def get_diff_generator(self):
  315. """parse all diff lines, construct a list of UnifiedDiff objects"""
  316. diff = UnifiedDiff([], None, None, [])
  317. headers = []
  318. for line in self._stream:
  319. line = decode(line)
  320. if diff.is_old_path(line):
  321. # This is a new diff when current hunk is not yet genreated or
  322. # is completed. We yield previous diff if exists and construct
  323. # a new one for this case. Otherwise it's acutally an 'old'
  324. # line starts with '--- '.
  325. #
  326. if (not diff._hunks or diff._hunks[-1].is_completed()):
  327. if diff._old_path and diff._new_path and diff._hunks:
  328. yield diff
  329. diff = UnifiedDiff(headers, line, None, [])
  330. headers = []
  331. else:
  332. diff._hunks[-1].append(diff.parse_hunk_line(line))
  333. elif diff.is_new_path(line) and diff._old_path:
  334. if not diff._new_path:
  335. diff._new_path = line
  336. else:
  337. diff._hunks[-1].append(diff.parse_hunk_line(line))
  338. elif diff.is_hunk_meta(line):
  339. hunk_meta = line
  340. try:
  341. old_addr, new_addr = diff.parse_hunk_meta(hunk_meta)
  342. except (IndexError, ValueError):
  343. raise RuntimeError('invalid hunk meta: %s' % hunk_meta)
  344. hunk = Hunk(headers, hunk_meta, old_addr, new_addr)
  345. headers = []
  346. diff._hunks.append(hunk)
  347. elif diff._hunks and not headers and (diff.is_old(line) or
  348. diff.is_new(line) or
  349. diff.is_common(line)):
  350. diff._hunks[-1].append(diff.parse_hunk_line(line))
  351. elif diff.is_eof(line):
  352. # ignore
  353. pass
  354. elif diff.is_only_in_dir(line) or diff.is_binary_differ(line):
  355. # 'Only in foo:' and 'Binary files ... differ' are considered
  356. # as separate diffs, so yield current diff, then this line
  357. #
  358. if diff._old_path and diff._new_path and diff._hunks:
  359. # Current diff is comppletely constructed
  360. yield diff
  361. headers.append(line)
  362. yield UnifiedDiff(headers, '', '', [])
  363. headers = []
  364. diff = UnifiedDiff([], None, None, [])
  365. else:
  366. # All other non-recognized lines are considered as headers or
  367. # hunk headers respectively
  368. #
  369. headers.append(line)
  370. # Validate and yield the last patch set if it is not yielded yet
  371. if diff._old_path:
  372. assert diff._new_path is not None
  373. if diff._hunks:
  374. assert len(diff._hunks[-1]._hunk_meta) > 0
  375. assert len(diff._hunks[-1]._hunk_list) > 0
  376. yield diff
  377. if headers:
  378. # Tolerate dangling headers, just yield a UnifiedDiff object with
  379. # only header lines
  380. #
  381. yield UnifiedDiff(headers, '', '', [])
  382. class DiffMarker(object):
  383. def __init__(self, side_by_side=False, width=0, tab_width=8, wrap=False):
  384. self._side_by_side = side_by_side
  385. self._width = width
  386. self._tab_width = tab_width
  387. self._wrap = wrap
  388. def markup(self, diff):
  389. """Returns a generator"""
  390. if self._side_by_side:
  391. for line in self._markup_side_by_side(diff):
  392. yield line
  393. else:
  394. for line in self._markup_traditional(diff):
  395. yield line
  396. def _markup_traditional(self, diff):
  397. """Returns a generator"""
  398. for line in diff._headers:
  399. yield self._markup_header(line)
  400. yield self._markup_old_path(diff._old_path)
  401. yield self._markup_new_path(diff._new_path)
  402. for hunk in diff._hunks:
  403. for hunk_header in hunk._hunk_headers:
  404. yield self._markup_hunk_header(hunk_header)
  405. yield self._markup_hunk_meta(hunk._hunk_meta)
  406. for old, new, changed in hunk.mdiff():
  407. if changed:
  408. if not old[0]:
  409. # The '+' char after \x00 is kept
  410. # DEBUG: yield 'NEW: %s %s\n' % (old, new)
  411. line = new[1].strip('\x00\x01')
  412. yield self._markup_new(line)
  413. elif not new[0]:
  414. # The '-' char after \x00 is kept
  415. # DEBUG: yield 'OLD: %s %s\n' % (old, new)
  416. line = old[1].strip('\x00\x01')
  417. yield self._markup_old(line)
  418. else:
  419. # DEBUG: yield 'CHG: %s %s\n' % (old, new)
  420. yield (self._markup_old('-') +
  421. self._markup_mix(old[1], 'red'))
  422. yield (self._markup_new('+') +
  423. self._markup_mix(new[1], 'green'))
  424. else:
  425. yield self._markup_common(' ' + old[1])
  426. def _markup_side_by_side(self, diff):
  427. """Returns a generator"""
  428. def _normalize(line):
  429. return (line
  430. .replace('\t', ' ' * self._tab_width)
  431. .replace('\n', '')
  432. .replace('\r', ''))
  433. def _fit_with_marker_mix(text, base_color):
  434. """Wrap input text which contains mdiff tags, markup at the
  435. meantime
  436. """
  437. out = [COLORS[base_color]]
  438. tag_re = re.compile(r'\x00[+^-]|\x01')
  439. while text:
  440. if text.startswith('\x00-'): # del
  441. out.append(COLORS['reverse'] + COLORS[base_color])
  442. text = text[2:]
  443. elif text.startswith('\x00+'): # add
  444. out.append(COLORS['reverse'] + COLORS[base_color])
  445. text = text[2:]
  446. elif text.startswith('\x00^'): # change
  447. out.append(COLORS['underline'] + COLORS[base_color])
  448. text = text[2:]
  449. elif text.startswith('\x01'): # reset
  450. if len(text) > 1:
  451. out.append(COLORS['reset'] + COLORS[base_color])
  452. text = text[1:]
  453. else:
  454. # FIXME: utf-8 wchar might break the rule here, e.g.
  455. # u'\u554a' takes double width of a single letter, also
  456. # this depends on your terminal font. I guess audience of
  457. # this tool never put that kind of symbol in their code :-)
  458. #
  459. out.append(text[0])
  460. text = text[1:]
  461. out.append(COLORS['reset'])
  462. return ''.join(out)
  463. # Set up number width, note last hunk might be empty
  464. try:
  465. (start, offset) = diff._hunks[-1]._old_addr
  466. max1 = start + offset - 1
  467. (start, offset) = diff._hunks[-1]._new_addr
  468. max2 = start + offset - 1
  469. except IndexError:
  470. max1 = max2 = 0
  471. num_width = max(len(str(max1)), len(str(max2)))
  472. # Set up line width
  473. width = self._width
  474. if width <= 0:
  475. # Autodetection of text width according to terminal size
  476. try:
  477. # Each line is like 'nnn TEXT nnn TEXT\n', so width is half of
  478. # [terminal size minus the line number columns and 3 separating
  479. # spaces
  480. #
  481. width = (terminal_size()[0] - num_width * 2 - 3) // 2
  482. except Exception:
  483. # If terminal detection failed, set back to default
  484. width = 80
  485. # Setup lineno and line format
  486. left_num_fmt = colorize('%%(left_num)%ds' % num_width, 'yellow')
  487. right_num_fmt = colorize('%%(right_num)%ds' % num_width, 'yellow')
  488. line_fmt = (left_num_fmt + ' %(left)s ' + COLORS['reset'] +
  489. right_num_fmt + ' %(right)s\n')
  490. # yield header, old path and new path
  491. for line in diff._headers:
  492. yield self._markup_header(line)
  493. yield self._markup_old_path(diff._old_path)
  494. yield self._markup_new_path(diff._new_path)
  495. # yield hunks
  496. for hunk in diff._hunks:
  497. for hunk_header in hunk._hunk_headers:
  498. yield self._markup_hunk_header(hunk_header)
  499. yield self._markup_hunk_meta(hunk._hunk_meta)
  500. for old, new, changed in hunk.mdiff():
  501. if old[0]:
  502. left_num = str(hunk._old_addr[0] + int(old[0]) - 1)
  503. else:
  504. left_num = ' '
  505. if new[0]:
  506. right_num = str(hunk._new_addr[0] + int(new[0]) - 1)
  507. else:
  508. right_num = ' '
  509. left = _normalize(old[1])
  510. right = _normalize(new[1])
  511. if changed:
  512. if not old[0]:
  513. left = ''
  514. right = right.rstrip('\x01')
  515. if right.startswith('\x00+'):
  516. right = right[2:]
  517. right = self._markup_new(right)
  518. elif not new[0]:
  519. left = left.rstrip('\x01')
  520. if left.startswith('\x00-'):
  521. left = left[2:]
  522. left = self._markup_old(left)
  523. right = ''
  524. else:
  525. left = _fit_with_marker_mix(left, 'red')
  526. right = _fit_with_marker_mix(right, 'green')
  527. else:
  528. left = self._markup_common(left)
  529. right = self._markup_common(right)
  530. if self._wrap:
  531. # Need to wrap long lines, so here we'll iterate,
  532. # shaving off `width` chars from both left and right
  533. # strings, until both are empty. Also, line number needs to
  534. # be printed only for the first part.
  535. lncur = left_num
  536. rncur = right_num
  537. while left or right:
  538. # Split both left and right lines, preserving escaping
  539. # sequences correctly.
  540. lcur, left, llen = strsplit(left, width)
  541. rcur, right, rlen = strsplit(right, width)
  542. # Pad left line with spaces if needed
  543. if llen < width:
  544. lcur = '%s%*s' % (lcur, width - llen, '')
  545. yield line_fmt % {
  546. 'left_num': lncur,
  547. 'left': lcur,
  548. 'right_num': rncur,
  549. 'right': rcur
  550. }
  551. # Clean line numbers for further iterations
  552. lncur = ''
  553. rncur = ''
  554. else:
  555. # Don't need to wrap long lines; instead, a trailing '>'
  556. # char needs to be appended.
  557. wrap_char = colorize('>', 'lightmagenta')
  558. left = strtrim(left, width, wrap_char, len(right) > 0)
  559. right = strtrim(right, width, wrap_char, False)
  560. yield line_fmt % {
  561. 'left_num': left_num,
  562. 'left': left,
  563. 'right_num': right_num,
  564. 'right': right
  565. }
  566. def _markup_header(self, line):
  567. return colorize(line, 'cyan')
  568. def _markup_old_path(self, line):
  569. return colorize(line, 'yellow')
  570. def _markup_new_path(self, line):
  571. return colorize(line, 'yellow')
  572. def _markup_hunk_header(self, line):
  573. return colorize(line, 'lightcyan')
  574. def _markup_hunk_meta(self, line):
  575. return colorize(line, 'lightblue')
  576. def _markup_common(self, line):
  577. return colorize(line, 'reset')
  578. def _markup_old(self, line):
  579. return colorize(line, 'lightred')
  580. def _markup_new(self, line):
  581. return colorize(line, 'green')
  582. def _markup_mix(self, line, base_color):
  583. del_code = COLORS['reverse'] + COLORS[base_color]
  584. add_code = COLORS['reverse'] + COLORS[base_color]
  585. chg_code = COLORS['underline'] + COLORS[base_color]
  586. rst_code = COLORS['reset'] + COLORS[base_color]
  587. line = line.replace('\x00-', del_code)
  588. line = line.replace('\x00+', add_code)
  589. line = line.replace('\x00^', chg_code)
  590. line = line.replace('\x01', rst_code)
  591. return colorize(line, base_color)
  592. def markup_to_pager(stream, opts):
  593. """Pipe unified diff stream to pager (less).
  594. Note: have to create pager Popen object before the translator Popen object
  595. in PatchStreamForwarder, otherwise the `stdin=subprocess.PIPE` would cause
  596. trouble to the translator pipe (select() never see EOF after input stream
  597. ended), most likely python bug 12607 (http://bugs.python.org/issue12607)
  598. which was fixed in python 2.7.3.
  599. See issue #30 (https://github.com/ymattw/ydiff/issues/30) for more
  600. information.
  601. """
  602. pager_cmd = ['less']
  603. if not os.getenv('LESS'):
  604. # Args stolen from git source: github.com/git/git/blob/master/pager.c
  605. pager_cmd.extend(['-FRSX', '--shift 1'])
  606. pager = subprocess.Popen(
  607. pager_cmd, stdin=subprocess.PIPE, stdout=sys.stdout)
  608. diffs = DiffParser(stream).get_diff_generator()
  609. for diff in diffs:
  610. marker = DiffMarker(side_by_side=opts.side_by_side, width=opts.width,
  611. tab_width=opts.tab_width, wrap=opts.wrap)
  612. color_diff = marker.markup(diff)
  613. for line in color_diff:
  614. pager.stdin.write(line.encode('utf-8'))
  615. pager.stdin.close()
  616. pager.wait()
  617. def check_command_status(arguments):
  618. """Return True if command returns 0."""
  619. try:
  620. return subprocess.call(
  621. arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
  622. except OSError:
  623. return False
  624. def revision_control_diff(args):
  625. """Return diff from revision control system."""
  626. for _, ops in VCS_INFO.items():
  627. if check_command_status(ops['probe']):
  628. return subprocess.Popen(
  629. ops['diff'] + args, stdout=subprocess.PIPE).stdout
  630. def revision_control_log(args):
  631. """Return log from revision control system."""
  632. for _, ops in VCS_INFO.items():
  633. if check_command_status(ops['probe']):
  634. return subprocess.Popen(
  635. ops['log'] + args, stdout=subprocess.PIPE).stdout
  636. def decode(line):
  637. """Decode UTF-8 if necessary."""
  638. if isinstance(line, unicode):
  639. return line
  640. for encoding in ['utf-8', 'latin1']:
  641. try:
  642. return line.decode(encoding)
  643. except UnicodeDecodeError:
  644. pass
  645. return '*** ydiff: undecodable bytes ***\n'
  646. def terminal_size():
  647. """Returns terminal size. Taken from https://gist.github.com/marsam/7268750
  648. but removed win32 support which depends on 3rd party extension.
  649. """
  650. width, height = None, None
  651. try:
  652. import struct
  653. import fcntl
  654. import termios
  655. s = struct.pack('HHHH', 0, 0, 0, 0)
  656. x = fcntl.ioctl(1, termios.TIOCGWINSZ, s)
  657. height, width = struct.unpack('HHHH', x)[0:2]
  658. except (IOError, AttributeError):
  659. pass
  660. return width, height
  661. def main():
  662. signal.signal(signal.SIGPIPE, signal.SIG_DFL)
  663. signal.signal(signal.SIGINT, signal.SIG_DFL)
  664. from optparse import (OptionParser, BadOptionError, AmbiguousOptionError,
  665. OptionGroup)
  666. class PassThroughOptionParser(OptionParser):
  667. """Stop parsing on first unknown option (e.g. --cached, -U10) and pass
  668. them down. Note the `opt_str` in exception object does not give us
  669. chance to take the full option back, e.g. for '-U10' it will only
  670. contain '-U' and the '10' part will be lost. Ref: http://goo.gl/IqY4A
  671. (on stackoverflow). My hack is to try parse and insert a '--' in place
  672. and parse again. Let me know if someone has better solution.
  673. """
  674. def _process_args(self, largs, rargs, values):
  675. left = largs[:]
  676. right = rargs[:]
  677. try:
  678. OptionParser._process_args(self, left, right, values)
  679. except (BadOptionError, AmbiguousOptionError):
  680. parsed_num = len(rargs) - len(right) - 1
  681. rargs.insert(parsed_num, '--')
  682. OptionParser._process_args(self, largs, rargs, values)
  683. supported_vcs = sorted(VCS_INFO.keys())
  684. usage = """%prog [options] [file|dir ...]"""
  685. parser = PassThroughOptionParser(
  686. usage=usage, description=META_INFO['description'],
  687. version='%%prog %s' % META_INFO['version'])
  688. parser.add_option(
  689. '-s', '--side-by-side', action='store_true',
  690. help='enable side-by-side mode')
  691. parser.add_option(
  692. '-w', '--width', type='int', default=80, metavar='N',
  693. help='set text width for side-by-side mode, 0 for auto detection, '
  694. 'default is 80')
  695. parser.add_option(
  696. '-l', '--log', action='store_true',
  697. help='show log with changes from revision control')
  698. parser.add_option(
  699. '-c', '--color', default='auto', metavar='M',
  700. help="""colorize mode 'auto' (default), 'always', or 'never'""")
  701. parser.add_option(
  702. '-t', '--tab-width', type='int', default=8, metavar='N',
  703. help="""convert tab characters to this many spcaes (default: 8)""")
  704. parser.add_option(
  705. '', '--wrap', action='store_true',
  706. help='wrap long lines in side-by-side view')
  707. # Hack: use OptionGroup text for extra help message after option list
  708. option_group = OptionGroup(
  709. parser, 'Note', ('Option parser will stop on first unknown option '
  710. 'and pass them down to underneath revision control. '
  711. 'Environment variable YDIFF_OPTIONS may be used to '
  712. 'specify default options that will be placed at the '
  713. 'beginning of the argument list.'))
  714. parser.add_option_group(option_group)
  715. # Place possible options defined in YDIFF_OPTIONS at the beginning of argv
  716. ydiff_opts = [x for x in os.getenv('YDIFF_OPTIONS', '').split(' ') if x]
  717. # TODO: Deprecate CDIFF_OPTIONS. Fall back to it and warn (for now).
  718. if not ydiff_opts:
  719. cdiff_opts = [x for x in os.getenv('CDIFF_OPTIONS', '').split(' ')
  720. if x]
  721. if cdiff_opts:
  722. sys.stderr.write('*** CDIFF_OPTIONS will be depreated soon, '
  723. 'please use YDIFF_OPTIONS instead\n')
  724. ydiff_opts = cdiff_opts
  725. opts, args = parser.parse_args(ydiff_opts + sys.argv[1:])
  726. if opts.log:
  727. diff_hdl = revision_control_log(args)
  728. if not diff_hdl:
  729. sys.stderr.write(('*** Not in a supported workspace, supported '
  730. 'are: %s\n') % ', '.join(supported_vcs))
  731. return 1
  732. elif sys.stdin.isatty():
  733. diff_hdl = revision_control_diff(args)
  734. if not diff_hdl:
  735. sys.stderr.write(('*** Not in a supported workspace, supported '
  736. 'are: %s\n\n') % ', '.join(supported_vcs))
  737. parser.print_help()
  738. return 1
  739. else:
  740. diff_hdl = (sys.stdin.buffer if hasattr(sys.stdin, 'buffer')
  741. else sys.stdin)
  742. stream = PatchStream(diff_hdl)
  743. # Don't let empty diff pass thru
  744. if stream.is_empty():
  745. return 0
  746. if (opts.color == 'always' or
  747. (opts.color == 'auto' and sys.stdout.isatty())):
  748. markup_to_pager(stream, opts)
  749. else:
  750. # pipe out stream untouched to make sure it is still a patch
  751. byte_output = (sys.stdout.buffer if hasattr(sys.stdout, 'buffer')
  752. else sys.stdout)
  753. for line in stream:
  754. byte_output.write(line)
  755. if diff_hdl is not sys.stdin:
  756. diff_hdl.close()
  757. return 0
  758. if __name__ == '__main__':
  759. sys.exit(main())
  760. # vim:set et sts=4 sw=4 tw=79: