Skip to content

Commit

Permalink
Print and show error end locations (#13148)
Browse files Browse the repository at this point in the history
Using the flag --show-error-end the errors will be output like 
file:line:column:end_line:end_column, for example:

```
x: int = "no way"
main:1:10:1:17: error: Incompatible types in assignment (expression has type "str", variable has type "int")
```

This will be helpful for various tools to highlight error location. Also when run with 
--pretty mypy itself will highlight the full span of error context (except if it spans 
multiple lines, like Python 3.11 itself does).
  • Loading branch information
ilevkivskyi authored Jul 20, 2022
1 parent 53465bd commit 962d7b4
Show file tree
Hide file tree
Showing 11 changed files with 156 additions and 32 deletions.
8 changes: 8 additions & 0 deletions docs/source/command_line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,14 @@ in error messages.

main.py:12:9: error: Unsupported operand types for / ("int" and "str")

.. option:: --show-error-end

This flag will make mypy show not just that start position where
an error was detected, but also the end position of the relevant expression.
This way various tools can easily highlight the whole error span. The format is
``file:line:column:end_line:end_column``. This option implies
``--show-column-numbers``.

.. option:: --show-error-codes

This flag will add an error code ``[<code>]`` to error messages. The error
Expand Down
1 change: 1 addition & 0 deletions mypy/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def _build(sources: List[BuildSource],
options.show_column_numbers,
options.show_error_codes,
options.pretty,
options.show_error_end,
lambda path: read_py_file(path, cached_read, options.python_version),
options.show_absolute_path,
options.enabled_error_codes,
Expand Down
85 changes: 60 additions & 25 deletions mypy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ class ErrorInfo:
# The column number related to this error with file.
column = 0 # -1 if unknown

# The end line number related to this error within file.
end_line = 0 # -1 if unknown

# The end column number related to this error with file.
end_column = 0 # -1 if unknown

# Either 'error' or 'note'
severity = ''

Expand Down Expand Up @@ -87,6 +93,8 @@ def __init__(self,
function_or_member: Optional[str],
line: int,
column: int,
end_line: int,
end_column: int,
severity: str,
message: str,
code: Optional[ErrorCode],
Expand All @@ -102,6 +110,8 @@ def __init__(self,
self.function_or_member = function_or_member
self.line = line
self.column = column
self.end_line = end_line
self.end_column = end_column
self.severity = severity
self.message = message
self.code = code
Expand All @@ -113,8 +123,10 @@ def __init__(self,


# Type used internally to represent errors:
# (path, line, column, severity, message, allow_dups, code)
# (path, line, column, end_line, end_column, severity, message, allow_dups, code)
ErrorTuple = Tuple[Optional[str],
int,
int,
int,
int,
str,
Expand Down Expand Up @@ -221,6 +233,10 @@ class Errors:
# Set to True to show column numbers in error messages.
show_column_numbers: bool = False

# Set to True to show end line and end column in error messages.
# Ths implies `show_column_numbers`.
show_error_end: bool = False

# Set to True to show absolute file paths in error messages.
show_absolute_path: bool = False

Expand All @@ -241,6 +257,7 @@ def __init__(self,
show_column_numbers: bool = False,
show_error_codes: bool = False,
pretty: bool = False,
show_error_end: bool = False,
read_source: Optional[Callable[[str], Optional[List[str]]]] = None,
show_absolute_path: bool = False,
enabled_error_codes: Optional[Set[ErrorCode]] = None,
Expand All @@ -251,6 +268,9 @@ def __init__(self,
self.show_error_codes = show_error_codes
self.show_absolute_path = show_absolute_path
self.pretty = pretty
self.show_error_end = show_error_end
if show_error_end:
assert show_column_numbers, "Inconsistent formatting, must be prevented by argparse"
# We use fscache to read source code when showing snippets.
self.read_source = read_source
self.enabled_error_codes = enabled_error_codes or set()
Expand Down Expand Up @@ -343,7 +363,8 @@ def report(self,
allow_dups: bool = False,
origin_line: Optional[int] = None,
offset: int = 0,
end_line: Optional[int] = None) -> None:
end_line: Optional[int] = None,
end_column: Optional[int] = None) -> None:
"""Report message at the given line using the current error context.
Args:
Expand All @@ -370,6 +391,12 @@ def report(self,

if column is None:
column = -1
if end_column is None:
if column == -1:
end_column = -1
else:
end_column = column + 1

if file is None:
file = self.file
if offset:
Expand All @@ -384,7 +411,7 @@ def report(self,
code = code or (codes.MISC if not blocker else None)

info = ErrorInfo(self.import_context(), file, self.current_module(), type,
function, line, column, severity, message, code,
function, line, column, end_line, end_column, severity, message, code,
blocker, only_once, allow_dups,
origin=(self.file, origin_line, end_line),
target=self.current_target())
Expand Down Expand Up @@ -470,7 +497,7 @@ def add_error_info(self, info: ErrorInfo) -> None:
'may be out of date')
note = ErrorInfo(
info.import_ctx, info.file, info.module, info.type, info.function_or_member,
info.line, info.column, 'note', msg,
info.line, info.column, info.end_line, info.end_column, 'note', msg,
code=None, blocker=False, only_once=False, allow_dups=False
)
self._add_error_info(file, note)
Expand Down Expand Up @@ -500,7 +527,9 @@ def report_hidden_errors(self, info: ErrorInfo) -> None:
typ=None,
function_or_member=None,
line=info.line,
column=info.line,
column=info.column,
end_line=info.end_line,
end_column=info.end_column,
severity='note',
message=message,
code=None,
Expand Down Expand Up @@ -571,7 +600,7 @@ def generate_unused_ignore_errors(self, file: str) -> None:
message = f'Unused "type: ignore{unused_codes_message}" comment'
# Don't use report since add_error_info will ignore the error!
info = ErrorInfo(self.import_context(), file, self.current_module(), None,
None, line, -1, 'error', message,
None, line, -1, line, -1, 'error', message,
None, False, False, False)
self._add_error_info(file, info)

Expand Down Expand Up @@ -606,7 +635,7 @@ def generate_ignore_without_code_errors(self,
message = f'"type: ignore" comment without error code{codes_hint}'
# Don't use report since add_error_info will ignore the error!
info = ErrorInfo(self.import_context(), file, self.current_module(), None,
None, line, -1, 'error', message, codes.IGNORE_WITHOUT_CODE,
None, line, -1, line, -1, 'error', message, codes.IGNORE_WITHOUT_CODE,
False, False, False)
self._add_error_info(file, info)

Expand Down Expand Up @@ -657,11 +686,13 @@ def format_messages(self, error_info: List[ErrorInfo],
error_info = [info for info in error_info if not info.hidden]
errors = self.render_messages(self.sort_messages(error_info))
errors = self.remove_duplicates(errors)
for file, line, column, severity, message, allow_dups, code in errors:
for file, line, column, end_line, end_column, severity, message, allow_dups, code in errors:
s = ''
if file is not None:
if self.show_column_numbers and line >= 0 and column >= 0:
srcloc = f'{file}:{line}:{1 + column}'
if self.show_error_end and end_line >=0 and end_column >= 0:
srcloc += f':{end_line}:{end_column}'
elif line >= 0:
srcloc = f'{file}:{line}'
else:
Expand All @@ -685,11 +716,15 @@ def format_messages(self, error_info: List[ErrorInfo],

# Shifts column after tab expansion
column = len(source_line[:column].expandtabs())
end_column = len(source_line[:end_column].expandtabs())

# Note, currently coloring uses the offset to detect source snippets,
# so these offsets should not be arbitrary.
a.append(' ' * DEFAULT_SOURCE_OFFSET + source_line_expanded)
a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + '^')
marker = '^'
if end_line == line and end_column > column:
marker = f'^{"~" * (end_column - column - 1)}'
a.append(' ' * (DEFAULT_SOURCE_OFFSET + column) + marker)
return a

def file_messages(self, path: str) -> List[str]:
Expand Down Expand Up @@ -763,7 +798,7 @@ def render_messages(self,
# Remove prefix to ignore from path (if present) to
# simplify path.
path = remove_path_prefix(path, self.ignore_prefix)
result.append((None, -1, -1, 'note',
result.append((None, -1, -1, -1, -1, 'note',
fmt.format(path, line), e.allow_dups, None))
i -= 1

Expand All @@ -776,32 +811,32 @@ def render_messages(self,
e.type != prev_type):
if e.function_or_member is None:
if e.type is None:
result.append((file, -1, -1, 'note', 'At top level:', e.allow_dups, None))
result.append((file, -1, -1, -1, -1, 'note', 'At top level:', e.allow_dups, None))
else:
result.append((file, -1, -1, 'note', 'In class "{}":'.format(
result.append((file, -1, -1, -1, -1, 'note', 'In class "{}":'.format(
e.type), e.allow_dups, None))
else:
if e.type is None:
result.append((file, -1, -1, 'note',
result.append((file, -1, -1, -1, -1, 'note',
'In function "{}":'.format(
e.function_or_member), e.allow_dups, None))
else:
result.append((file, -1, -1, 'note',
result.append((file, -1, -1, -1, -1, 'note',
'In member "{}" of class "{}":'.format(
e.function_or_member, e.type), e.allow_dups, None))
elif e.type != prev_type:
if e.type is None:
result.append((file, -1, -1, 'note', 'At top level:', e.allow_dups, None))
result.append((file, -1, -1, -1, -1, 'note', 'At top level:', e.allow_dups, None))
else:
result.append((file, -1, -1, 'note',
result.append((file, -1, -1, -1, -1, 'note',
f'In class "{e.type}":', e.allow_dups, None))

if isinstance(e.message, ErrorMessage):
result.append(
(file, e.line, e.column, e.severity, e.message.value, e.allow_dups, e.code))
(file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message.value, e.allow_dups, e.code))
else:
result.append(
(file, e.line, e.column, e.severity, e.message, e.allow_dups, e.code))
(file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message, e.allow_dups, e.code))

prev_import_context = e.import_ctx
prev_function_or_member = e.function_or_member
Expand Down Expand Up @@ -842,21 +877,21 @@ def remove_duplicates(self, errors: List[ErrorTuple]) -> List[ErrorTuple]:
conflicts_notes = False
j = i - 1
# Find duplicates, unless duplicates are allowed.
if not errors[i][5]:
if not errors[i][7]:
while j >= 0 and errors[j][0] == errors[i][0]:
if errors[j][4].strip() == 'Got:':
if errors[j][6].strip() == 'Got:':
conflicts_notes = True
j -= 1
j = i - 1
while (j >= 0 and errors[j][0] == errors[i][0] and
errors[j][1] == errors[i][1]):
if (errors[j][3] == errors[i][3] and
if (errors[j][5] == errors[i][5] and
# Allow duplicate notes in overload conflicts reporting.
not ((errors[i][3] == 'note' and
errors[i][4].strip() in allowed_duplicates)
or (errors[i][4].strip().startswith('def ') and
not ((errors[i][5] == 'note' and
errors[i][6].strip() in allowed_duplicates)
or (errors[i][6].strip().startswith('def ') and
conflicts_notes)) and
errors[j][4] == errors[i][4]): # ignore column
errors[j][6] == errors[i][6]): # ignore column
dup = True
break
j -= 1
Expand Down
8 changes: 8 additions & 0 deletions mypy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,10 @@ def add_invertible_flag(flag: str,
add_invertible_flag('--show-column-numbers', default=False,
help="Show column numbers in error messages",
group=error_group)
add_invertible_flag('--show-error-end', default=False,
help="Show end line/end column numbers in error messages."
" This implies --show-column-numbers",
group=error_group)
add_invertible_flag('--show-error-codes', default=False,
help="Show error codes in error messages",
group=error_group)
Expand Down Expand Up @@ -1036,6 +1040,10 @@ def set_strict_flags() -> None:
if options.cache_fine_grained:
options.local_partial_types = True

# Implicitly show column numbers if error location end is shown
if options.show_error_end:
options.show_column_numbers = True

# Let logical_deps imply cache_fine_grained (otherwise the former is useless).
if options.logical_deps:
options.cache_fine_grained = True
Expand Down
4 changes: 3 additions & 1 deletion mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ def report(self,
context.get_column() if context else -1,
msg, severity=severity, file=file, offset=offset,
origin_line=origin.get_line() if origin else None,
end_line=end_line, code=code, allow_dups=allow_dups)
end_line=end_line,
end_column=context.end_column if context else -1,
code=code, allow_dups=allow_dups)

def fail(self,
msg: str,
Expand Down
1 change: 1 addition & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ def __init__(self) -> None:
# -- experimental options --
self.shadow_file: Optional[List[List[str]]] = None
self.show_column_numbers: bool = False
self.show_error_end: bool = False
self.show_error_codes = False
# Use soft word wrap and show trimmed source snippets with error location markers.
self.pretty = False
Expand Down
16 changes: 13 additions & 3 deletions mypy/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,16 +648,26 @@ def fit_in_terminal(self, messages: List[str],
# TODO: detecting source code highlights through an indent can be surprising.
# Restore original error message and error location.
error = error[DEFAULT_SOURCE_OFFSET:]
column = messages[i+1].index('^') - DEFAULT_SOURCE_OFFSET
marker_line = messages[i+1]
marker_column = marker_line.index('^')
column = marker_column - DEFAULT_SOURCE_OFFSET
if '~' not in marker_line:
marker = '^'
else:
# +1 because both ends are included
marker = marker_line[marker_column:marker_line.rindex('~')+1]

# Let source have some space also on the right side, plus 6
# to accommodate ... on each side.
max_len = width - DEFAULT_SOURCE_OFFSET - 6
source_line, offset = trim_source_line(error, max_len, column, MINIMUM_WIDTH)

new_messages[i] = ' ' * DEFAULT_SOURCE_OFFSET + source_line
# Also adjust the error marker position.
new_messages[i+1] = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + '^'
# Also adjust the error marker position and trim error marker is needed.
new_marker_line = ' ' * (DEFAULT_SOURCE_OFFSET + column - offset) + marker
if len(new_marker_line) > len(new_messages[i]) and len(marker) > 3:
new_marker_line = new_marker_line[:len(new_messages[i]) - 3] + '...'
new_messages[i+1] = new_marker_line
return new_messages

def colorize(self, error: str) -> str:
Expand Down
18 changes: 18 additions & 0 deletions test-data/unit/check-columns.test
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,21 @@ def f(x: T) -> T:
[case testColumnReturnValueExpected]
def f() -> int:
return # E:5: Return value expected

[case testCheckEndColumnPositions]
# flags: --show-error-end
x: int = "no way"

def g() -> int: ...
def f(x: str) -> None: ...
f(g(
))
x[0]
[out]
main:2:10:2:10: error: Incompatible types in assignment (expression has type "str", variable has type "int")
main:6:3:6:3: error: Argument 1 to "f" has incompatible type "int"; expected "str"
main:8:1:8:1: error: Value of type "int" is not indexable
[out version>=3.8]
main:2:10:2:17: error: Incompatible types in assignment (expression has type "str", variable has type "int")
main:6:3:7:1: error: Argument 1 to "f" has incompatible type "int"; expected "str"
main:8:1:8:4: error: Value of type "int" is not indexable
Loading

0 comments on commit 962d7b4

Please sign in to comment.