-
Notifications
You must be signed in to change notification settings - Fork 285
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This also adds an `is_saved` argument to the pyls_lint hookspec, since pylint doesn't expose an API for operating on in-memory contents, only files.
- Loading branch information
Showing
5 changed files
with
253 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
# Copyright 2018 Google LLC. | ||
"""Linter plugin for pylint.""" | ||
import collections | ||
import json | ||
import sys | ||
|
||
from pylint.epylint import py_run | ||
from pyls import hookimpl, lsp | ||
|
||
|
||
class PylintLinter(object): | ||
last_diags = collections.defaultdict(list) | ||
|
||
@classmethod | ||
def lint(cls, document, is_saved, flags=''): | ||
"""Plugin interface to pyls linter. | ||
Args: | ||
document: The document to be linted. | ||
is_saved: Whether or not the file has been saved to disk. | ||
flags: Additional flags to pass to pylint. Not exposed to | ||
pyls_lint, but used for testing. | ||
Returns: | ||
A list of dicts with the following format: | ||
{ | ||
'source': 'pylint', | ||
'range': { | ||
'start': { | ||
'line': start_line, | ||
'character': start_column, | ||
}, | ||
'end': { | ||
'line': end_line, | ||
'character': end_column, | ||
}, | ||
} | ||
'message': msg, | ||
'severity': lsp.DiagnosticSeverity.*, | ||
} | ||
""" | ||
if not is_saved: | ||
# Pylint can only be run on files that have been saved to disk. | ||
# Rather than return nothing, return the previous list of | ||
# diagnostics. If we return an empty list, any diagnostics we'd | ||
# previously shown will be cleared until the next save. Instead, | ||
# continue showing (possibly stale) diagnostics until the next | ||
# save. | ||
return cls.last_diags[document.path] | ||
|
||
# py_run will call shlex.split on its arguments, and shlex.split does | ||
# not handle Windows paths (it will try to perform escaping). Turn | ||
# backslashes into forward slashes first to avoid this issue. | ||
path = document.path | ||
if sys.platform.startswith('win'): | ||
path = path.replace('\\', '/') | ||
out, _err = py_run( | ||
'{} -f json {}'.format(path, flags), return_std=True) | ||
|
||
# pylint prints nothing rather than [] when there are no diagnostics. | ||
# json.loads will not parse an empty string, so just return. | ||
json_str = out.getvalue() | ||
if not json_str.strip(): | ||
cls.last_diags[document.path] = [] | ||
return [] | ||
|
||
# Pylint's JSON output is a list of objects with the following format. | ||
# | ||
# { | ||
# "obj": "main", | ||
# "path": "foo.py", | ||
# "message": "Missing function docstring", | ||
# "message-id": "C0111", | ||
# "symbol": "missing-docstring", | ||
# "column": 0, | ||
# "type": "convention", | ||
# "line": 5, | ||
# "module": "foo" | ||
# } | ||
# | ||
# The type can be any of: | ||
# | ||
# * convention | ||
# * error | ||
# * fatal | ||
# * refactor | ||
# * warning | ||
diagnostics = [] | ||
for diag in json.loads(json_str): | ||
# pylint lines index from 1, pyls lines index from 0 | ||
line = diag['line'] - 1 | ||
# But both index columns from 0 | ||
col = diag['column'] | ||
|
||
# It's possible that we're linting an empty file. Even an empty | ||
# file might fail linting if it isn't named properly. | ||
end_col = len(document.lines[line]) if document.lines else 0 | ||
|
||
err_range = { | ||
'start': { | ||
'line': line, | ||
'character': col, | ||
}, | ||
'end': { | ||
'line': line, | ||
'character': end_col, | ||
}, | ||
} | ||
|
||
if diag['type'] == 'convention': | ||
severity = lsp.DiagnosticSeverity.Information | ||
elif diag['type'] == 'error': | ||
severity = lsp.DiagnosticSeverity.Error | ||
elif diag['type'] == 'fatal': | ||
severity = lsp.DiagnosticSeverity.Error | ||
elif diag['type'] == 'refactor': | ||
severity = lsp.DiagnosticSeverity.Hint | ||
elif diag['type'] == 'warning': | ||
severity = lsp.DiagnosticSeverity.Warning | ||
|
||
diagnostics.append({ | ||
'source': 'pylint', | ||
'range': err_range, | ||
'message': '[{}] {}'.format(diag['symbol'], diag['message']), | ||
'severity': severity, | ||
'code': diag['message-id'] | ||
}) | ||
cls.last_diags[document.path] = diagnostics | ||
return diagnostics | ||
|
||
|
||
@hookimpl | ||
def pyls_lint(document, is_saved): | ||
return PylintLinter.lint(document, is_saved) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
# Copyright 2018 Google LLC. | ||
import contextlib | ||
import os | ||
import tempfile | ||
|
||
from pyls import lsp, uris | ||
from pyls.workspace import Document | ||
from pyls.plugins import pylint_lint | ||
|
||
DOC_URI = uris.from_fs_path(__file__) | ||
DOC = """import sys | ||
def hello(): | ||
\tpass | ||
import json | ||
""" | ||
|
||
DOC_SYNTAX_ERR = """def hello() | ||
pass | ||
""" | ||
|
||
|
||
@contextlib.contextmanager | ||
def temp_document(doc_text): | ||
try: | ||
temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False) | ||
name = temp_file.name | ||
temp_file.write(doc_text) | ||
temp_file.close() | ||
yield Document(uris.from_fs_path(name)) | ||
finally: | ||
os.remove(name) | ||
|
||
|
||
def write_temp_doc(document, contents): | ||
with open(document.path, 'w') as temp_file: | ||
temp_file.write(contents) | ||
|
||
|
||
def test_pylint(): | ||
with temp_document(DOC) as doc: | ||
diags = pylint_lint.pyls_lint(doc, True) | ||
|
||
msg = '[unused-import] Unused import sys' | ||
unused_import = [d for d in diags if d['message'] == msg][0] | ||
|
||
assert unused_import['range']['start'] == {'line': 0, 'character': 0} | ||
assert unused_import['severity'] == lsp.DiagnosticSeverity.Warning | ||
|
||
|
||
def test_syntax_error_pylint(): | ||
with temp_document(DOC_SYNTAX_ERR) as doc: | ||
diag = pylint_lint.pyls_lint(doc, True)[0] | ||
|
||
assert diag['message'].startswith('[syntax-error] invalid syntax') | ||
# Pylint doesn't give column numbers for invalid syntax. | ||
assert diag['range']['start'] == {'line': 0, 'character': 0} | ||
assert diag['severity'] == lsp.DiagnosticSeverity.Error | ||
|
||
|
||
def test_lint_free_pylint(): | ||
# Can't use temp_document because it might give us a file that doesn't | ||
# match pylint's naming requirements. We should be keeping this file clean | ||
# though, so it works for a test of an empty lint. | ||
assert not pylint_lint.pyls_lint( | ||
Document(uris.from_fs_path(__file__)), True) | ||
|
||
|
||
def test_lint_caching(): | ||
# Pylint can only operate on files, not in-memory contents. We cache the | ||
# diagnostics after a run so we can continue displaying them until the file | ||
# is saved again. | ||
# | ||
# We use PylintLinter.lint directly here rather than pyls_lint so we can | ||
# pass --disable=invalid-name to pylint, since we want a temporary file but | ||
# need to ensure that pylint doesn't give us invalid-name when our temp | ||
# file has capital letters in its name. | ||
|
||
flags = '--disable=invalid-name' | ||
with temp_document(DOC) as doc: | ||
# Start with a file with errors. | ||
diags = pylint_lint.PylintLinter.lint(doc, True, flags) | ||
assert diags | ||
|
||
# Fix lint errors and write the changes to disk. Run the linter in the | ||
# in-memory mode to check the cached diagnostic behavior. | ||
write_temp_doc(doc, '') | ||
assert pylint_lint.PylintLinter.lint(doc, False, flags) == diags | ||
|
||
# Now check the on-disk behavior. | ||
assert not pylint_lint.PylintLinter.lint(doc, True, flags) | ||
|
||
# Make sure the cache was properly cleared. | ||
assert not pylint_lint.PylintLinter.lint(doc, False, flags) | ||
|
||
|
||
def test_per_file_caching(): | ||
# Ensure that diagnostics are cached per-file. | ||
with temp_document(DOC) as doc: | ||
assert pylint_lint.pyls_lint(doc, True) | ||
|
||
assert not pylint_lint.pyls_lint( | ||
Document(uris.from_fs_path(__file__)), False) |