diagnostic_interface.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. # Copyright (C) 2013-2018 YouCompleteMe contributors
  2. #
  3. # This file is part of YouCompleteMe.
  4. #
  5. # YouCompleteMe is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # YouCompleteMe is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with YouCompleteMe. If not, see <http://www.gnu.org/licenses/>.
  17. from collections import defaultdict
  18. from ycm import vimsupport
  19. from ycm.diagnostic_filter import DiagnosticFilter, CompileLevel
  20. from ycm import text_properties as tp
  21. import vim
  22. YCM_VIM_PROPERTY_ID = 1
  23. class DiagnosticInterface:
  24. def __init__( self, bufnr, user_options ):
  25. self._bufnr = bufnr
  26. self._user_options = user_options
  27. self._diagnostics = []
  28. self._diag_filter = DiagnosticFilter.CreateFromOptions( user_options )
  29. # Line and column numbers are 1-based
  30. self._line_to_diags = defaultdict( list )
  31. self._previous_diag_line_number = -1
  32. self._diag_message_needs_clearing = False
  33. def ShouldUpdateDiagnosticsUINow( self ):
  34. return ( self._user_options[ 'update_diagnostics_in_insert_mode' ] or
  35. 'i' not in vim.eval( 'mode()' ) )
  36. def OnCursorMoved( self ):
  37. if self._user_options[ 'echo_current_diagnostic' ]:
  38. line, _ = vimsupport.CurrentLineAndColumn()
  39. line += 1 # Convert to 1-based
  40. if not self.ShouldUpdateDiagnosticsUINow():
  41. # Clear any previously echo'd diagnostic in insert mode
  42. self._EchoDiagnosticText( line, None, None )
  43. elif line != self._previous_diag_line_number:
  44. self._EchoDiagnosticForLine( line )
  45. def GetErrorCount( self ):
  46. return self._DiagnosticsCount( _DiagnosticIsError )
  47. def GetWarningCount( self ):
  48. return self._DiagnosticsCount( _DiagnosticIsWarning )
  49. def PopulateLocationList( self, open_on_edit = False ):
  50. # Do nothing if loc list is already populated by diag_interface
  51. if not self._user_options[ 'always_populate_location_list' ]:
  52. self._UpdateLocationLists( open_on_edit )
  53. return bool( self._diagnostics )
  54. def UpdateWithNewDiagnostics( self, diags, open_on_edit = False ):
  55. self._diagnostics = [ _NormalizeDiagnostic( x ) for x in
  56. self._ApplyDiagnosticFilter( diags ) ]
  57. self._ConvertDiagListToDict()
  58. if self.ShouldUpdateDiagnosticsUINow():
  59. self.RefreshDiagnosticsUI( open_on_edit )
  60. def RefreshDiagnosticsUI( self, open_on_edit = False ):
  61. if self._user_options[ 'echo_current_diagnostic' ]:
  62. self._EchoDiagnostic()
  63. if self._user_options[ 'enable_diagnostic_signs' ]:
  64. self._UpdateSigns()
  65. self.UpdateMatches()
  66. if self._user_options[ 'always_populate_location_list' ]:
  67. self._UpdateLocationLists( open_on_edit )
  68. def ClearDiagnosticsUI( self ):
  69. if self._user_options[ 'echo_current_diagnostic' ]:
  70. self._ClearCurrentDiagnostic()
  71. if self._user_options[ 'enable_diagnostic_signs' ]:
  72. self._ClearSigns()
  73. self._ClearMatches()
  74. def DiagnosticsForLine( self, line_number ):
  75. return self._line_to_diags[ line_number ]
  76. def _ApplyDiagnosticFilter( self, diags ):
  77. filetypes = vimsupport.GetBufferFiletypes( self._bufnr )
  78. diag_filter = self._diag_filter.SubsetForTypes( filetypes )
  79. return filter( diag_filter.IsAllowed, diags )
  80. def _EchoDiagnostic( self ):
  81. line, _ = vimsupport.CurrentLineAndColumn()
  82. line += 1 # Convert to 1-based
  83. self._EchoDiagnosticForLine( line )
  84. def _EchoDiagnosticForLine( self, line_num ):
  85. self._previous_diag_line_number = line_num
  86. diags = self._line_to_diags[ line_num ]
  87. text = None
  88. first_diag = None
  89. if diags:
  90. first_diag = diags[ 0 ]
  91. text = first_diag[ 'text' ]
  92. if first_diag.get( 'fixit_available', False ):
  93. text += ' (FixIt)'
  94. self._EchoDiagnosticText( line_num, first_diag, text )
  95. def _ClearCurrentDiagnostic( self, will_be_replaced=False ):
  96. if not self._diag_message_needs_clearing:
  97. return
  98. if ( vimsupport.VimSupportsVirtualText() and
  99. self._user_options[ 'echo_current_diagnostic' ] == 'virtual-text' ):
  100. tp.ClearTextProperties( self._bufnr,
  101. prop_types = [ 'YcmVirtDiagPadding',
  102. 'YcmVirtDiagError',
  103. 'YcmVirtDiagWarning' ] )
  104. else:
  105. if not will_be_replaced:
  106. vimsupport.PostVimMessage( '', warning = False )
  107. self._diag_message_needs_clearing = False
  108. def _EchoDiagnosticText( self, line_num, first_diag, text ):
  109. self._ClearCurrentDiagnostic( bool( text ) )
  110. if ( vimsupport.VimSupportsVirtualText() and
  111. self._user_options[ 'echo_current_diagnostic' ] == 'virtual-text' ):
  112. if not text:
  113. return
  114. def MakeVritualTextProperty( prop_type, text, position='after' ):
  115. vimsupport.AddTextProperty( self._bufnr,
  116. line_num,
  117. 0,
  118. prop_type,
  119. {
  120. 'text': text,
  121. 'text_align': position,
  122. 'text_wrap': 'wrap'
  123. } )
  124. if vim.options[ 'ambiwidth' ] != 'double':
  125. marker = '⚠'
  126. else:
  127. marker = '>'
  128. MakeVritualTextProperty(
  129. 'YcmVirtDiagPadding',
  130. ' ' * vim.buffers[ self._bufnr ].options[ 'shiftwidth' ] ),
  131. MakeVritualTextProperty(
  132. 'YcmVirtDiagError' if _DiagnosticIsError( first_diag )
  133. else 'YcmVirtDiagWarning',
  134. marker + ' ' + [ line for line in text.splitlines() if line ][ 0 ] )
  135. else:
  136. if not text:
  137. # We already cleared it
  138. return
  139. vimsupport.PostVimMessage( text, warning = False, truncate = True )
  140. self._diag_message_needs_clearing = True
  141. def _DiagnosticsCount( self, predicate ):
  142. count = 0
  143. for diags in self._line_to_diags.values():
  144. count += sum( 1 for d in diags if predicate( d ) )
  145. return count
  146. def _UpdateLocationLists( self, open_on_edit = False ):
  147. vimsupport.SetLocationListsForBuffer(
  148. self._bufnr,
  149. vimsupport.ConvertDiagnosticsToQfList( self._diagnostics ),
  150. open_on_edit )
  151. def _ClearMatches( self ):
  152. props_to_remove = vimsupport.GetTextProperties( self._bufnr )
  153. for prop in props_to_remove:
  154. vimsupport.RemoveDiagnosticProperty( self._bufnr, prop )
  155. def UpdateMatches( self ):
  156. if not self._user_options[ 'enable_diagnostic_highlighting' ]:
  157. return
  158. props_to_remove = vimsupport.GetTextProperties( self._bufnr )
  159. for diags in self._line_to_diags.values():
  160. # Insert squiggles in reverse order so that errors overlap warnings.
  161. for diag in reversed( diags ):
  162. for line, column, name, extras in _ConvertDiagnosticToTextProperties(
  163. self._bufnr,
  164. diag ):
  165. global YCM_VIM_PROPERTY_ID
  166. # Note the following .remove() works because the __eq__ on
  167. # DiagnosticProperty does not actually check the IDs match...
  168. diag_prop = vimsupport.DiagnosticProperty(
  169. YCM_VIM_PROPERTY_ID,
  170. name,
  171. line,
  172. column,
  173. extras[ 'end_col' ] - column if 'end_col' in extras else column )
  174. try:
  175. props_to_remove.remove( diag_prop )
  176. except ValueError:
  177. extras.update( {
  178. 'id': YCM_VIM_PROPERTY_ID
  179. } )
  180. vimsupport.AddTextProperty( self._bufnr,
  181. line,
  182. column,
  183. name,
  184. extras )
  185. YCM_VIM_PROPERTY_ID += 1
  186. for prop in props_to_remove:
  187. vimsupport.RemoveDiagnosticProperty( self._bufnr, prop )
  188. def _ClearSigns( self ):
  189. signs_to_unplace = vimsupport.GetSignsInBuffer( self._bufnr )
  190. vim.eval( f'sign_unplacelist( { signs_to_unplace } )' )
  191. def _UpdateSigns( self ):
  192. signs_to_unplace = vimsupport.GetSignsInBuffer( self._bufnr )
  193. signs_to_place = []
  194. for line, diags in self._line_to_diags.items():
  195. if not diags:
  196. continue
  197. # We always go for the first diagnostic on the line because diagnostics
  198. # are sorted by errors in priority and Vim can only display one sign by
  199. # line.
  200. name = 'YcmError' if _DiagnosticIsError( diags[ 0 ] ) else 'YcmWarning'
  201. sign = {
  202. 'lnum': line,
  203. 'name': name,
  204. 'buffer': self._bufnr,
  205. 'group': 'ycm_signs'
  206. }
  207. try:
  208. signs_to_unplace.remove( sign )
  209. except ValueError:
  210. signs_to_place.append( sign )
  211. vim.eval( f'sign_placelist( { signs_to_place } )' )
  212. vim.eval( f'sign_unplacelist( { signs_to_unplace } )' )
  213. def _ConvertDiagListToDict( self ):
  214. self._line_to_diags = defaultdict( list )
  215. for diag in self._diagnostics:
  216. location_extent = diag[ 'location_extent' ]
  217. start = location_extent[ 'start' ]
  218. end = location_extent[ 'end' ]
  219. bufnr = vimsupport.GetBufferNumberForFilename( start[ 'filepath' ] )
  220. if bufnr == self._bufnr:
  221. for line_number in range( start[ 'line_num' ], end[ 'line_num' ] + 1 ):
  222. self._line_to_diags[ line_number ].append( diag )
  223. for diags in self._line_to_diags.values():
  224. # We also want errors to be listed before warnings so that errors aren't
  225. # hidden by the warnings; Vim won't place a sign over an existing one.
  226. diags.sort( key = lambda diag: ( diag[ 'kind' ],
  227. diag[ 'location' ][ 'column_num' ] ) )
  228. _DiagnosticIsError = CompileLevel( 'error' )
  229. _DiagnosticIsWarning = CompileLevel( 'warning' )
  230. def _NormalizeDiagnostic( diag ):
  231. def ClampToOne( value ):
  232. return value if value > 0 else 1
  233. location = diag[ 'location' ]
  234. location[ 'column_num' ] = ClampToOne( location[ 'column_num' ] )
  235. location[ 'line_num' ] = ClampToOne( location[ 'line_num' ] )
  236. return diag
  237. def _ConvertDiagnosticToTextProperties( bufnr, diagnostic ):
  238. properties = []
  239. name = ( 'YcmErrorProperty' if _DiagnosticIsError( diagnostic ) else
  240. 'YcmWarningProperty' )
  241. if vimsupport.VimIsNeovim():
  242. name = name.replace( 'Property', 'Section' )
  243. location_extent = diagnostic[ 'location_extent' ]
  244. if location_extent[ 'start' ][ 'line_num' ] <= 0:
  245. location = diagnostic[ 'location' ]
  246. line, column = vimsupport.LineAndColumnNumbersClamped(
  247. bufnr,
  248. location[ 'line_num' ],
  249. location[ 'column_num' ]
  250. )
  251. properties.append( ( line, column, name, {} ) )
  252. else:
  253. start_line, start_column = vimsupport.LineAndColumnNumbersClamped(
  254. bufnr,
  255. location_extent[ 'start' ][ 'line_num' ],
  256. location_extent[ 'start' ][ 'column_num' ]
  257. )
  258. end_line, end_column = vimsupport.LineAndColumnNumbersClamped(
  259. bufnr,
  260. location_extent[ 'end' ][ 'line_num' ],
  261. location_extent[ 'end' ][ 'column_num' ]
  262. )
  263. properties.append( (
  264. start_line,
  265. start_column,
  266. name,
  267. { 'end_lnum': end_line,
  268. 'end_col': end_column } ) )
  269. for diagnostic_range in diagnostic[ 'ranges' ]:
  270. if ( diagnostic_range[ 'start' ][ 'line_num' ] == 0 or
  271. diagnostic_range[ 'end' ][ 'line_num' ] == 0 ):
  272. continue
  273. start_line, start_column = vimsupport.LineAndColumnNumbersClamped(
  274. bufnr,
  275. diagnostic_range[ 'start' ][ 'line_num' ],
  276. diagnostic_range[ 'start' ][ 'column_num' ]
  277. )
  278. end_line, end_column = vimsupport.LineAndColumnNumbersClamped(
  279. bufnr,
  280. diagnostic_range[ 'end' ][ 'line_num' ],
  281. diagnostic_range[ 'end' ][ 'column_num' ]
  282. )
  283. if not _IsValidRange( start_line, start_column, end_line, end_column ):
  284. continue
  285. properties.append( (
  286. start_line,
  287. start_column,
  288. name,
  289. { 'end_lnum': end_line,
  290. 'end_col': end_column } ) )
  291. return properties
  292. def _IsValidRange( start_line, start_column, end_line, end_column ):
  293. # End line before start line - invalid
  294. if start_line > end_line:
  295. return False
  296. # End line after start line - valid
  297. if start_line < end_line:
  298. return True
  299. # Same line, start colum after end column - invalid
  300. if start_column > end_column:
  301. return False
  302. # Same line, start column before or equal to end column - valid
  303. return True