diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 10416766f2615..7955d755e797e 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -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 ``[]`` to error messages. The error diff --git a/mypy/build.py b/mypy/build.py index 1196356d5bb8b..1829ad4d06949 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -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, diff --git a/mypy/errors.py b/mypy/errors.py index 0ad56b079eccd..697e87a209a38 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -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 = '' @@ -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], @@ -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 @@ -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, @@ -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 @@ -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, @@ -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() @@ -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: @@ -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: @@ -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()) @@ -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) @@ -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, @@ -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) @@ -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) @@ -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: @@ -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]: @@ -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 @@ -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 @@ -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 diff --git a/mypy/main.py b/mypy/main.py index 14b318ead3e7a..a36bb30b293f9 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -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) @@ -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 diff --git a/mypy/messages.py b/mypy/messages.py index 628c2cbaf0a44..47d8669a6c4fd 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -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, diff --git a/mypy/options.py b/mypy/options.py index 254af61a06456..de76d549b20fd 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -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 diff --git a/mypy/util.py b/mypy/util.py index 03db281ef6152..957fabbb06867 100644 --- a/mypy/util.py +++ b/mypy/util.py @@ -648,7 +648,14 @@ 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. @@ -656,8 +663,11 @@ def fit_in_terminal(self, messages: List[str], 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: diff --git a/test-data/unit/check-columns.test b/test-data/unit/check-columns.test index 3220119748e3a..219befe1bb7d9 100644 --- a/test-data/unit/check-columns.test +++ b/test-data/unit/check-columns.test @@ -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 diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index 3b3b64c038c14..ad7a0215c1e76 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -1112,7 +1112,7 @@ mypy: can't read file 'missing.py': No such file or directory == Return code: 2 [case testShowSourceCodeSnippetsWrappedFormatting] -# cmd: mypy --pretty --python-version=3.6 some_file.py +# cmd: mypy --pretty some_file.py [file some_file.py] from typing import Union @@ -1138,7 +1138,19 @@ some_file.py:11: error: Argument 1 to "some_interesting_method" of "AnotherCustomClassDefinedBelow" ...OneCustomClassName = OneCustomClassName().some_interesting_method(arg) ^ - +[out version>=3.8] +some_file.py:3: error: Unsupported operand types for + ("int" and "str") + 42 + 'no way' + ^~~~~~~~ +some_file.py:11: error: Incompatible types in assignment (expression has type +"AnotherCustomClassDefinedBelow", variable has type "OneCustomClassName") + ...t_attribute_with_long_name: OneCustomClassName = OneCustomClassName().... + ^~~~~~~~~~~~~~~~~~~~~... +some_file.py:11: error: Argument 1 to "some_interesting_method" of +"OneCustomClassName" has incompatible type "Union[int, str, float]"; expected +"AnotherCustomClassDefinedBelow" + ...OneCustomClassName = OneCustomClassName().some_interesting_method(arg) + ^~~ [case testShowSourceCodeSnippetsBlockingError] # cmd: mypy --pretty --show-error-codes some_file.py [file some_file.py] @@ -1154,10 +1166,24 @@ some_file.py:1: error: invalid syntax [syntax] [file tabs.py] def test_tabs() -> str: return None +def test_between(x: str) -> None: ... +test_between(1 + 1) [out] tabs.py:2: error: Incompatible return value type (got "None", expected "str") return None ^ +tabs.py:4: error: Argument 1 to "test_between" has incompatible type "int"; +expected "str" + test_between(1 + 1) + ^ +[out version>=3.8] +tabs.py:2: error: Incompatible return value type (got "None", expected "str") + return None + ^~~~ +tabs.py:4: error: Argument 1 to "test_between" has incompatible type "int"; +expected "str" + test_between(1 + 1) + ^~~~~~~~~~~~ [case testErrorMessageWhenOpenPydFile] # cmd: mypy a.pyd diff --git a/test-data/unit/fine-grained-blockers.test b/test-data/unit/fine-grained-blockers.test index 66a68115afa53..45de0013616d3 100644 --- a/test-data/unit/fine-grained-blockers.test +++ b/test-data/unit/fine-grained-blockers.test @@ -52,6 +52,16 @@ main:3: error: Missing positional argument "x" in call to "f" [call-arg] a.f() ^ == +[out version>=3.8] +== +a.py:1: error: invalid syntax [syntax] + def f(x: int) -> + ^ +== +main:3: error: Missing positional argument "x" in call to "f" [call-arg] + a.f() + ^~~~~ +== [out version>=3.10] == a.py:1: error: expected ':' [syntax] @@ -60,7 +70,7 @@ a.py:1: error: expected ':' [syntax] == main:3: error: Missing positional argument "x" in call to "f" [call-arg] a.f() - ^ + ^~~~~ == [case testParseErrorMultipleTimes] diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index c2bd67320f3ff..f58cad116bb65 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -98,6 +98,11 @@ class A: main:5: error: Missing positional argument "a" in call to "g" of "A" [call-arg] a.g() # E ^ +[out version>=3.8] +== +main:5: error: Missing positional argument "a" in call to "g" of "A" [call-arg] + a.g() # E + ^~~~~ [case testFunctionMissingModuleAttribute] import m