forked from SublimeLinter/SublimeLinter-template
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathlinter.py
331 lines (266 loc) · 12 KB
/
linter.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
from os import path
from shlex import quote
import logging
import json
import re
import sublime_plugin
from SublimeLinter import lint
logger = logging.getLogger("SublimeLinter.plugins.phpstan")
class AutoLintOnTabSwitchListener(sublime_plugin.ViewEventListener):
@classmethod
def is_applicable(cls, settings):
return True
def on_activated_async(self):
if self.view.file_name() and self.view.file_name().endswith(".php"):
self.view.run_command("sublime_linter_lint")
class PhpStan(lint.Linter):
regex = None
error_stream = lint.STREAM_STDOUT
default_type = "error"
multiline = False
tempfile_suffix = "-"
defaults = {
"selector": "embedding.php, source.php"
}
def cmd(self):
cmd = ["phpstan", "analyse"]
opts = ["--error-format=json", "--no-progress"]
# if we have arguments for configuration and autoload file, we don't need to find them
if self.have_argument("--configuration") and self.have_argument("--autoload-file"):
return cmd + ["${args}"] + opts + ["--", "${file}"]
configPath = self.find_phpstan_configuration(self.view.file_name())
if configPath:
opts.append("--configuration={}".format(quote(configPath)))
autoload_file = self.find_autoload_php(configPath)
if autoload_file:
opts.append("--autoload-file={}".format(quote(autoload_file)))
cmd[0] = autoload_file.replace("/autoload.php", "/bin/phpstan")
else:
print("⚠️ Fallback on PHPStan installed globally")
else:
print("⚠️ phpstan.neon has not been found - Fallback on PHPStan installed globally")
return cmd + ["${args}"] + opts + ["--", "${file}"]
def have_argument(self, name):
if self.settings.get('args'):
for arg in self.settings.get('args'):
if arg.startswith(name):
return True
return False
def find_autoload_php(self, configPath):
pathAutoLoad = configPath.replace("/phpstan.neon", "/vendor/autoload.php")
if (path.isfile(pathAutoLoad)):
return pathAutoLoad
return None
def find_phpstan_configuration(self, file_path):
basedir = None
while file_path:
basedir = path.dirname(file_path)
configFiles = (
"{basedir}/phpstan.neon".format(basedir=basedir),
"{basedir}/phpstan.neon.dist".format(basedir=basedir),
)
for configFile in configFiles:
if (path.isfile(configFile)):
return configFile
if (basedir == file_path):
break
file_path = basedir
def find_errors(self, output):
try:
content = json.loads(output)
except ValueError:
logger.error(
"JSON Decode error: We expected JSON from PHPStan, "
"but instead got this:\n{}\n\n"
.format(output)
)
self.notify_failure()
return
if 'files' not in content:
return
for file in content['files']:
for error in content['files'][file]['messages']:
# If there is a tip we should display it instead of error
# as it is more useful to solve the problem
error_message = error['message']
# If ignorable is false, then display show_quick_panle
if 'ignorable' in error and not error['ignorable']:
error_list.append(error_message)
if 'tip' in error:
# the character • is used for list of tips
tip = error['tip'].replace("•", "💡")
if not tip.startswith("💡"):
tip = "💡 " + tip
error_message = error_message + "\n" + tip
line_region = self.view.line(self.view.text_point(error['line'] - 1, 0))
line_content = self.view.substr(line_region)
stripped_line = line_content.lstrip()
leading_whitespace_length = len(line_content) - len(stripped_line)
# Highlight the whole line in which the error is reported by default
key = self.extract_offset_key(error)
col = leading_whitespace_length
end_col = len(line_content)
# Try to check if we can find the position of the key in the line
if key:
pos = self.find_position_key(key, line_content)
if pos is not None:
col = pos[0]
end_col = pos[1]
yield lint.LintMatch(
match=error,
filename=file,
line=error['line'] - 1,
col=col,
end_col=end_col,
message=error_message,
error_type='error',
code='',
)
def extract_offset_key(self, error):
# If there is no identifier, we can't extract
if 'identifier' not in error:
return None
identifier = error['identifier']
if identifier == 'return.type':
return 'return'
elif identifier == 'method.visibility':
return 'private'
elif identifier == 'constructor.missingParentCall':
return '__construct'
# List of regex patterns per error identifier
patterns = {
'argument.type': [
r'::(\w+)\(\)',
r'function (\w+)',
r'Method [\w\\]+::(\w+)\(\) is unused\.',
],
'arguments.count': [
r'Method [\w\\]+::(\w+)\(\) invoked with \d+ parameters, \d+ required\.',
r'::(\w+)\(\)',
],
'assign.propertyReadOnly': r'Property object\{[^}]*\b[^}]*\}::\$(\w+) is not writable\.',
'assign.propertyType': [
r'does not accept [\w\\]+\\(\w+)\.',
r'::\$(\w+)',
r'::(\$\w+)',
],
'class.notFound': [
r'on an unknown class [\w\\]+\\(\w+)\.',
r'has unknown class [\w\\]+\\(\w+) as its type\.',
r'Instantiated class [\w\\]+\\(\w+) not found\.',
r'Parameter \$\w+ of method [\w\\]+::\w+\(\) has invalid type (\w+)\.',
r'Call to method (\w+)\(\) on an unknown class (\w+)\.',
r'Method [\w\\]+::\w+\(\) has invalid return type (\w+)\.',
r'extends unknown class [\w\\]+\\(\w+)\.'
],
'classConstant.notFound': r'(::\w+)\.',
'constant.notFound': r'Constant (\w+) not found\.',
'constructor.unusedParameter': r'Constructor of class [\w\\]+ has an unused parameter (\$\w+)\.',
'function.nameCase': r'incorrect case: (\w+)',
'function.notFound': r'Function (\w+) not found\.',
'function.strict': r'Call to function (\w+)\(\)',
'interface.notFound': r'implements unknown interface [\w\\]+\\(\w+)\.',
'isset.offset': r'static property [\w\\]+::(\$\w+)',
'method.childParameterType': r'Parameter #\d+ \$(\w+)',
'method.nameCase': r'incorrect case: (\w+)',
'method.nonObject': r'\b([a-zA-Z_]\w*)\(\)',
'method.notFound': r'Call to an undefined method [\w\\]+::(\w+)\(\)\.',
'method.unused': r'::(\w+)\(\)',
'method.void': r'Result of method [\w\\]+::(\w+)\(\)',
'missingType.iterableValue': r'Method [\w\\]+::\w+\(\) has parameter (\$\w+) with no value type specified in iterable type array\.',
'missingType.parameter': r'Method [\w\\]+::\w+\(\) has parameter (\$\w+) with no type specified\.',
'missingType.property': r'Property [\w\\]+::(\$\w+) has no type specified\.',
'missingType.return': r'::(\w+)\(\)',
'offsetAccess.notFound': r"Offset '([^']+)'",
'property.notFound': r'::\$(\w+)',
'property.nonObject': r'property \$([\w_]+) on',
'property.onlyRead': r'::\$(\w+)',
'property.onlyWritten': [
r'Property [\w\\]+::(\$\w+) is never read, only written\.',
r'Static property [\w\\]+::(\$\w+) is never read, only written\.',
],
'property.readOnlyAssignNotInConstructor': r'Cannot assign to a read-only property [\w\\]+::\$(\w+)',
'property.uninitializedReadonly': [
r'(\$\w+)',
r'::\$(\w+)',
],
'property.unused': [
r'Property [\w\\]+::(\$\w+) is unused\.',
r'Static property [\w\\]+::(\$\w+) is unused\.',
],
'return.phpDocType': r'native type (\w+)',
'return.unusedType': r'never returns (\w+)',
'staticMethod.notFound': r'undefined static method (\w+::\w+)\(\)\.',
'staticMethod.void': r'static method [\w\\]+::(\w+)\(\)',
'staticProperty.notFound': r'static property [\w\\]+::(\$\w+)',
'variable.undefined': [
r'Undefined variable: (\$\w+)',
r'Variable (\$\w+) might not be defined\.'
],
}
key = self.parse_pattern(patterns, error)
if key is not None:
if identifier == 'property.uninitializedReadonly':
# remove the first character $
return key[1:]
is_static = False
if 'static' in error['message']:
is_static = True
if not is_static and identifier in {'method.nonObject', 'property.notFound', 'property.nonObject', 'assign.propertyType'}:
return "->" + key
else:
if identifier == 'missingType.iterableValue':
return ": array"
if identifier == 'property.onlyRead':
return "readonly"
return key
def parse_pattern(self, patterns, error):
error_message = error['message']
identifier = error['identifier']
if identifier in patterns:
pattern = patterns[identifier]
if isinstance(pattern, list):
for pat in pattern:
match = re.search(pat, error_message)
if match:
return match.group(1)
else:
match = re.search(pattern, error_message)
if match:
return match.group(1)
return None
def find_position_key(self, key, line_content):
pattern = rf"{key}"
# Check if key begins with $
if key.startswith('$'):
pattern = rf"\{key}"
# Below we will do 3 searches, the first 2 because of associative arrays
# can use ' or ". Example $data["index"] and $data['index']
#
# Search with single quote '
key_match = re.search("\\['" + pattern + "'\\]", line_content)
if key_match:
col = key_match.start() + 1
end_col = key_match.end() - 1
return col, end_col
# Search with double quote "
key_match = re.search('\\["' + pattern + '"\\]', line_content)
if key_match:
col = key_match.start() + 1
end_col = key_match.end() - 1
return col, end_col
# Original search, without any quote
key_match = re.search(pattern, line_content)
if key_match:
# Compute the start and end columns
col = key_match.start()
end_col = key_match.end()
# Adjust to the actual position of the key of the object
if key.startswith('->'):
col = key_match.start() + 2
elif key.startswith(': '):
col = key_match.start() + 2
# Include $ if there is $ just before the key
if line_content[col-1:col] == '$':
col = col - 1
return col, end_col