import_js 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,15 +3,20 @@ require 'open3'
3
3
 
4
4
  module ImportJS
5
5
  class Importer
6
+ REGEX_USE_STRICT = /(['"])use strict\1;?/
7
+ REGEX_SINGLE_LINE_COMMENT = %r{\A\s*//}
8
+ REGEX_MULTI_LINE_COMMENT_START = %r{\A\s*/\*}
9
+ REGEX_MULTI_LINE_COMMENT_END = %r{\*/}
10
+ REGEX_WHITESPACE_ONLY = /\A\s*\Z/
11
+
6
12
  def initialize(editor = ImportJS::VIMEditor.new)
7
- @config = ImportJS::Configuration.new
8
13
  @editor = editor
9
14
  end
10
15
 
11
16
  # Finds variable under the cursor to import. By default, this is bound to
12
17
  # `<Leader>j`.
13
18
  def import
14
- @config.refresh
19
+ @config = ImportJS::Configuration.new(@editor.path_to_current_file)
15
20
  variable_name = @editor.current_word
16
21
  if variable_name.empty?
17
22
  message(<<-EOS.split.join(' '))
@@ -20,48 +25,67 @@ module ImportJS
20
25
  EOS
21
26
  return
22
27
  end
23
- current_row, current_col = @editor.cursor
24
28
 
25
- old_buffer_lines = @editor.count_lines
26
29
  js_module = find_one_js_module(variable_name)
27
30
  return unless js_module
28
31
 
29
- old_imports = find_current_imports
30
- inject_js_module(variable_name, js_module, old_imports[:imports])
31
- replace_imports(old_imports[:newline_count],
32
- old_imports[:imports],
33
- old_imports[:imports_start_at])
34
- lines_changed = @editor.count_lines - old_buffer_lines
35
- return unless lines_changed
36
- @editor.cursor = [current_row + lines_changed, current_col]
32
+ maintain_cursor_position do
33
+ old_imports = find_current_imports
34
+ inject_js_module(variable_name, js_module, old_imports[:imports])
35
+ replace_imports(old_imports[:newline_count],
36
+ old_imports[:imports],
37
+ old_imports[:imports_start_at])
38
+ end
37
39
  end
38
40
 
39
41
  def goto
40
- @config.refresh
41
- @timing = { start: Time.now }
42
+ @config = ImportJS::Configuration.new(@editor.path_to_current_file)
43
+ js_modules = []
42
44
  variable_name = @editor.current_word
43
- js_modules = find_js_modules(variable_name)
44
- @timing[:end] = Time.now
45
+ time do
46
+ js_modules = find_js_modules(variable_name)
47
+ end
45
48
  return if js_modules.empty?
46
49
  js_module = resolve_one_js_module(js_modules, variable_name)
47
- @editor.open_file(js_module.file_path) if js_module
50
+ if js_module
51
+ @editor.open_file(js_module.open_file_path(@editor.path_to_current_file))
52
+ end
48
53
  end
49
54
 
55
+ REGEX_ESLINT_RESULT = /
56
+ (?<quote>["']) # <quote> opening quote
57
+ (?<variable_name>[^\1]+) # <variable_name>
58
+ \k<quote>
59
+ \s
60
+ (?<type> # <type>
61
+ is\sdefined\sbut\snever\sused # is defined but never used
62
+ |
63
+ is\snot\sdefined # is not defined
64
+ |
65
+ must\sbe\sin\sscope\swhen\susing\sJSX # must be in scope when using JSX
66
+ )
67
+ /x
68
+
50
69
  # Removes unused imports and adds imports for undefined variables
51
70
  def fix_imports
52
- @config.refresh
71
+ @config = ImportJS::Configuration.new(@editor.path_to_current_file)
53
72
  eslint_result = run_eslint_command
54
- undefined_variables = eslint_result.map do |line|
55
- /(["'])([^"']+)\1 is not defined/.match(line) do |match_data|
56
- match_data[2]
57
- end
58
- end.compact.uniq
59
73
 
60
- unused_variables = eslint_result.map do |line|
61
- /"([^"]+)" is defined but never used/.match(line) do |match_data|
62
- match_data[1]
74
+ unused_variables = []
75
+ undefined_variables = []
76
+
77
+ eslint_result.each do |line|
78
+ match = REGEX_ESLINT_RESULT.match(line)
79
+ next unless match
80
+ if match[:type] == 'is defined but never used'
81
+ unused_variables << match[:variable_name]
82
+ else
83
+ undefined_variables << match[:variable_name]
63
84
  end
64
- end.compact.uniq
85
+ end
86
+
87
+ unused_variables.uniq!
88
+ undefined_variables.uniq!
65
89
 
66
90
  old_imports = find_current_imports
67
91
  new_imports = old_imports[:imports].reject do |import_statement|
@@ -72,9 +96,8 @@ module ImportJS
72
96
  end
73
97
 
74
98
  undefined_variables.each do |variable|
75
- if js_module = find_one_js_module(variable)
76
- inject_js_module(variable, js_module, new_imports)
77
- end
99
+ js_module = find_one_js_module(variable)
100
+ inject_js_module(variable, js_module, new_imports) if js_module
78
101
  end
79
102
 
80
103
  replace_imports(old_imports[:newline_count],
@@ -88,10 +111,26 @@ module ImportJS
88
111
  @editor.message("ImportJS: #{str}")
89
112
  end
90
113
 
114
+ ESLINT_STDOUT_ERROR_REGEXES = [
115
+ /Parsing error: /,
116
+ /Unrecoverable syntax error/,
117
+ /<text>:0:0: Cannot find module '.*'/,
118
+ ].freeze
119
+
120
+ ESLINT_STDERR_ERROR_REGEXES = [
121
+ /SyntaxError: /,
122
+ /eslint: command not found/,
123
+ /Cannot read config package: /,
124
+ /Cannot find module '.*'/,
125
+ /No such file or directory/,
126
+ ].freeze
127
+
91
128
  # @return [Array<String>] the output from eslint, line by line
92
129
  def run_eslint_command
93
- command = @config.get('eslint_executable') + ' ' + %w[
130
+ command = %W[
131
+ #{@config.get('eslint_executable')}
94
132
  --stdin
133
+ --stdin-filename #{@editor.path_to_current_file}
95
134
  --format unix
96
135
  --rule 'no-undef: 2'
97
136
  --rule 'no-unused-vars: [2, { "vars": "all", "args": "none" }]'
@@ -99,17 +138,11 @@ module ImportJS
99
138
  out, err = Open3.capture3(command,
100
139
  stdin_data: @editor.current_file_content)
101
140
 
102
- if out =~ /Parsing error: / ||
103
- out =~ /Unrecoverable syntax error/ ||
104
- out =~ /<text>:0:0: Cannot find module '.*'/
141
+ if ESLINT_STDOUT_ERROR_REGEXES.any? { |regex| out =~ regex }
105
142
  fail ImportJS::ParseError.new, out
106
143
  end
107
144
 
108
- if err =~ /SyntaxError: / ||
109
- err =~ /eslint: command not found/ ||
110
- err =~ /Cannot read config package: / ||
111
- err =~ /Cannot find module '.*'/ ||
112
- err =~ /No such file or directory/
145
+ if ESLINT_STDERR_ERROR_REGEXES.any? { |regex| err =~ regex }
113
146
  fail ImportJS::ParseError.new, err
114
147
  end
115
148
 
@@ -119,9 +152,10 @@ module ImportJS
119
152
  # @param variable_name [String]
120
153
  # @return [ImportJS::JSModule?]
121
154
  def find_one_js_module(variable_name)
122
- @timing = { start: Time.now }
123
- js_modules = find_js_modules(variable_name)
124
- @timing[:end] = Time.now
155
+ js_modules = []
156
+ time do
157
+ js_modules = find_js_modules(variable_name)
158
+ end
125
159
  if js_modules.empty?
126
160
  message(
127
161
  "No JS module to import for variable `#{variable_name}` #{timing}")
@@ -136,45 +170,55 @@ module ImportJS
136
170
  # @param js_module [ImportJS::JSModule]
137
171
  # @param imports [Array<ImportJS::ImportStatement>]
138
172
  def inject_js_module(variable_name, js_module, imports)
139
- import = imports.find { |import| import.path == js_module.import_path }
173
+ import = imports.find do |an_import|
174
+ an_import.path == js_module.import_path
175
+ end
140
176
 
141
177
  if import
142
- if js_module.is_destructured
143
- import.inject_destructured_variable(variable_name)
178
+ import.declaration_keyword = @config.get(
179
+ 'declaration_keyword', from_file: js_module.file_path)
180
+ import.import_function = @config.get(
181
+ 'import_function', from_file: js_module.file_path)
182
+ if js_module.has_named_exports
183
+ import.inject_named_import(variable_name)
144
184
  else
145
- import.set_default_variable(variable_name)
185
+ import.set_default_import(variable_name)
146
186
  end
147
187
  else
148
- imports.unshift(js_module.to_import_statement(variable_name))
188
+ imports.unshift(js_module.to_import_statement(variable_name, @config))
149
189
  end
150
190
 
151
191
  # Remove duplicate import statements
152
192
  imports.uniq!(&:to_normalized)
153
193
  end
154
194
 
195
+ # @param imports [Array<ImportJS::ImportStatement>]
196
+ # @return [String]
197
+ def generate_import_strings(import_statements)
198
+ import_statements.map do |import|
199
+ import.to_import_strings(@editor.max_line_length, @editor.tab)
200
+ end.flatten.sort
201
+ end
202
+
155
203
  # @param old_imports_lines [Number]
156
204
  # @param new_imports [Array<ImportJS::ImportStatement>]
157
205
  # @param imports_start_at [Number]
158
206
  def replace_imports(old_imports_lines, new_imports, imports_start_at)
207
+ imports_end_at = old_imports_lines + imports_start_at
208
+
159
209
  # Ensure that there is a blank line after the block of all imports
160
210
  if old_imports_lines + new_imports.length > 0 &&
161
- !@editor.read_line(old_imports_lines + imports_start_at + 1).strip.empty?
162
- @editor.append_line(old_imports_lines + imports_start_at, '')
211
+ !@editor.read_line(imports_end_at + 1).strip.empty?
212
+ @editor.append_line(imports_end_at, '')
163
213
  end
164
214
 
165
- # Generate import strings
166
- import_strings = new_imports.map do |import|
167
- import.to_import_strings(
168
- @config.get('declaration_keyword'),
169
- @editor.max_line_length,
170
- @editor.tab)
171
- end.flatten.sort
215
+ import_strings = generate_import_strings(new_imports)
172
216
 
173
217
  # Find old import strings so we can compare with the new import strings
174
218
  # and see if anything has changed.
175
219
  old_import_strings = []
176
- old_imports_lines.times do |line|
177
- old_import_strings << @editor.read_line(1 + line + imports_start_at)
220
+ (imports_start_at...imports_end_at).each do |line_index|
221
+ old_import_strings << @editor.read_line(line_index + 1)
178
222
  end
179
223
 
180
224
  # If nothing has changed, bail to prevent unnecessarily dirtying the
@@ -187,29 +231,61 @@ module ImportJS
187
231
  # We need to add each line individually because the Vim buffer will
188
232
  # convert newline characters to `~@`.
189
233
  import_string.split("\n").reverse_each do |line|
190
- @editor.append_line(0 + imports_start_at, line)
234
+ @editor.append_line(imports_start_at, line)
191
235
  end
192
236
  end
193
237
  end
194
238
 
195
- # @return [Hash]
196
- def find_current_imports
197
- potential_import_lines = []
198
- @editor.count_lines.times do |n|
199
- line = @editor.read_line(n + 1)
200
- break if line.strip.empty?
201
- potential_import_lines << line
239
+ # @return [Number]
240
+ def find_imports_start_line_index
241
+ imports_start_line_index = 0
242
+
243
+ # Skip over things at the top, like "use strict" and comments.
244
+ inside_multi_line_comment = false
245
+ matched_non_whitespace_line = false
246
+ (0...@editor.count_lines).each do |line_index|
247
+ line = @editor.read_line(line_index + 1)
248
+
249
+ if inside_multi_line_comment || line =~ REGEX_MULTI_LINE_COMMENT_START
250
+ matched_non_whitespace_line = true
251
+ imports_start_line_index = line_index + 1
252
+ inside_multi_line_comment = !(line =~ REGEX_MULTI_LINE_COMMENT_END)
253
+ next
254
+ end
255
+
256
+ if line =~ REGEX_USE_STRICT || line =~ REGEX_SINGLE_LINE_COMMENT
257
+ matched_non_whitespace_line = true
258
+ imports_start_line_index = line_index + 1
259
+ next
260
+ end
261
+
262
+ if line =~ REGEX_WHITESPACE_ONLY
263
+ imports_start_line_index = line_index + 1
264
+ next
265
+ end
266
+
267
+ break
202
268
  end
203
269
 
270
+ # We don't want to skip over blocks that are only whitespace
271
+ return imports_start_line_index if matched_non_whitespace_line
272
+ 0
273
+ end
274
+
275
+ # @return [Hash]
276
+ def find_current_imports
204
277
  result = {
205
278
  imports: [],
206
279
  newline_count: 0,
207
- imports_start_at: 0
280
+ imports_start_at: find_imports_start_line_index,
208
281
  }
209
282
 
210
- if potential_import_lines[0] =~ /(['"])use strict\1;?/
211
- result[:imports_start_at] = 1
212
- potential_import_lines.shift
283
+ # Find block of lines that might be imports.
284
+ potential_import_lines = []
285
+ (result[:imports_start_at]...@editor.count_lines).each do |line_index|
286
+ line = @editor.read_line(line_index + 1)
287
+ break if line.strip.empty?
288
+ potential_import_lines << line
213
289
  end
214
290
 
215
291
  # We need to put the potential imports back into a blob in order to scan
@@ -224,7 +300,7 @@ module ImportJS
224
300
  break unless import_statement
225
301
 
226
302
  if imports[import_statement.path]
227
- # Import already exists, so this line is likely one of a destructuring
303
+ # Import already exists, so this line is likely one of a named imports
228
304
  # pair. Combine it into the same ImportStatement.
229
305
  imports[import_statement.path].merge(import_statement)
230
306
  else
@@ -242,64 +318,81 @@ module ImportJS
242
318
  # @return [Array]
243
319
  def find_js_modules(variable_name)
244
320
  path_to_current_file = @editor.path_to_current_file
245
- if alias_module = @config.resolve_alias(variable_name,
246
- path_to_current_file)
247
- return [alias_module]
248
- end
321
+
322
+ alias_module = @config.resolve_alias(variable_name, path_to_current_file)
323
+ return [alias_module] if alias_module
324
+
325
+ named_imports_module = @config.resolve_named_exports(variable_name)
326
+ return [named_imports_module] if named_imports_module
327
+
328
+ formatted_var_name = formatted_to_regex(variable_name)
249
329
  egrep_command =
250
- "egrep -i \"(/|^)#{formatted_to_regex(variable_name)}(/index)?(/package)?\.js.*\""
330
+ "egrep -i \"(/|^)#{formatted_var_name}(/index)?(/package)?\.js.*\""
251
331
  matched_modules = []
252
332
  @config.get('lookup_paths').each do |lookup_path|
333
+ if lookup_path == ''
334
+ # If lookup_path is an empty string, the `find` command will not work
335
+ # as desired so we bail early.
336
+ fail ImportJS::FindError.new,
337
+ "lookup path cannot be empty (#{lookup_path.inspect})"
338
+ end
339
+
253
340
  find_command = %W[
254
341
  find #{lookup_path}
255
342
  -name "**.js*"
256
343
  -not -path "./node_modules/*"
257
344
  ].join(' ')
258
- out, _ = Open3.capture3("#{find_command} | #{egrep_command}")
345
+ command = "#{find_command} | #{egrep_command}"
346
+ out, err = Open3.capture3(command)
347
+
348
+ fail ImportJS::FindError.new, err unless err == ''
349
+
259
350
  matched_modules.concat(
260
351
  out.split("\n").map do |f|
261
352
  next if @config.get('excludes').any? do |glob_pattern|
262
353
  File.fnmatch(glob_pattern, f)
263
354
  end
264
- js_module = ImportJS::JSModule.new(
355
+ ImportJS::JSModule.construct(
265
356
  lookup_path: lookup_path,
266
357
  relative_file_path: f,
267
- strip_file_extensions: @config.get('strip_file_extensions'),
268
- make_relative_to: @config.get('use_relative_paths') &&
269
- path_to_current_file
358
+ strip_file_extensions:
359
+ @config.get('strip_file_extensions', from_file: f),
360
+ make_relative_to:
361
+ @config.get('use_relative_paths', from_file: f) &&
362
+ path_to_current_file,
363
+ strip_from_path:
364
+ @config.get('strip_from_path', from_file: f)
270
365
  )
271
-
272
- next if js_module.skip
273
- js_module
274
366
  end.compact
275
367
  )
276
368
  end
277
369
 
278
370
  # Find imports from package.json
371
+ ignore_prefixes = @config.get('ignore_package_prefixes').map do |prefix|
372
+ Regexp.escape(prefix)
373
+ end
374
+ dep_regex = /^(?:#{ignore_prefixes.join('|')})?#{formatted_var_name}$/
375
+
279
376
  @config.package_dependencies.each do |dep|
280
- ignore_prefixes = @config.get('ignore_package_prefixes')
281
- dep_matcher = /^#{formatted_to_regex(variable_name)}$/
282
- if dep =~ dep_matcher ||
283
- ignore_prefixes.any? do |prefix|
284
- dep.sub(/^#{prefix}/, '') =~ dep_matcher
285
- end
286
- js_module = ImportJS::JSModule.new(
287
- lookup_path: 'node_modules',
288
- relative_file_path: "node_modules/#{dep}/package.json",
289
- strip_file_extensions: [])
290
- next if js_module.skip
291
- matched_modules << js_module
292
- end
377
+ next unless dep =~ dep_regex
378
+
379
+ js_module = ImportJS::JSModule.construct(
380
+ lookup_path: 'node_modules',
381
+ relative_file_path: "node_modules/#{dep}/package.json",
382
+ strip_file_extensions: [])
383
+ matched_modules << js_module if js_module
293
384
  end
294
385
 
295
386
  # If you have overlapping lookup paths, you might end up seeing the same
296
387
  # module to import twice. In order to dedupe these, we remove the module
297
388
  # with the longest path
298
- matched_modules.sort do |a, b|
389
+ matched_modules.sort! do |a, b|
299
390
  a.import_path.length <=> b.import_path.length
300
- end.uniq do |m|
391
+ end
392
+ matched_modules.uniq! do |m|
301
393
  m.lookup_path + '/' + m.import_path
302
- end.sort do |a, b|
394
+ end
395
+ matched_modules.sort! do |a, b|
303
396
  a.display_name <=> b.display_name
304
397
  end
305
398
  end
@@ -309,8 +402,15 @@ module ImportJS
309
402
  # @return [String]
310
403
  def resolve_one_js_module(js_modules, variable_name)
311
404
  if js_modules.length == 1
312
- message("Imported `#{js_modules.first.display_name}` #{timing}")
313
- return js_modules.first
405
+ js_module = js_modules.first
406
+ js_module_name = js_module.display_name
407
+ imported = if js_module.has_named_exports
408
+ "`#{variable_name}` from `#{js_module_name}`"
409
+ else
410
+ "`#{js_module_name}`"
411
+ end
412
+ message("Imported #{imported} #{timing}")
413
+ return js_module
314
414
  end
315
415
 
316
416
  selected_index = @editor.ask_for_selection(
@@ -356,9 +456,34 @@ module ImportJS
356
456
  .downcase
357
457
  end
358
458
 
459
+ def time
460
+ timing = { start: Time.now }
461
+ yield
462
+ timing[:end] = Time.now
463
+ @timing = timing
464
+ end
465
+
359
466
  # @return [String]
360
467
  def timing
361
468
  "(#{(@timing[:end] - @timing[:start]).round(2)}s)"
362
469
  end
470
+
471
+ def maintain_cursor_position
472
+ # Save editor information before modifying the buffer so we can put the
473
+ # cursor in the correct spot after modifying the buffer.
474
+ current_row, current_col = @editor.cursor
475
+ old_buffer_lines = @editor.count_lines
476
+
477
+ # Yield to a block that will potentially modify the buffer.
478
+ yield
479
+
480
+ # Check to see if lines were added or removed.
481
+ lines_changed = @editor.count_lines - old_buffer_lines
482
+ return unless lines_changed
483
+
484
+ # Lines were added or removed, so we want to adjust the cursor position to
485
+ # match.
486
+ @editor.cursor = [current_row + lines_changed, current_col]
487
+ end
363
488
  end
364
489
  end