isort 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/isort-0.2.0.gem +0 -0
- data/lib/isort/file_processor.rb +426 -0
- data/lib/isort/import_block.rb +164 -0
- data/lib/isort/import_statement.rb +126 -0
- data/lib/isort/parser.rb +173 -0
- data/lib/isort/section.rb +197 -0
- data/lib/isort/syntax_validator.rb +75 -0
- data/lib/isort/version.rb +1 -1
- data/lib/isort/wrap_modes.rb +240 -0
- data/test_local.rb +106 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a833de16ba9dbfd28c133288adf5a2a2ad107e1e5932db200d479b412165d133
|
|
4
|
+
data.tar.gz: 5b36d59223838c1c3518b3cb8f2f3c25171e3a296f3b3bb3f9504c58c75e18ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 486aa5493eaad1646391877546e68934a1a80d0a81ab45a45284460035938875d94ce09a81cf5750bbb4f088dfdfcf9c8ff3eb0ed60a0a9955a5a80290775698
|
|
7
|
+
data.tar.gz: b24356a0840dfc7e414df6594c10503e1ae583d005c54771b50bf32676d2f4a6f133fe991c4340a3598c7adcec01507a6ebf79cdd79fbf07b9af08f6e8d9ffe8
|
data/isort-0.2.0.gem
ADDED
|
Binary file
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
require_relative "import_block"
|
|
5
|
+
require_relative "import_statement"
|
|
6
|
+
require_relative "syntax_validator"
|
|
7
|
+
|
|
8
|
+
module Isort
|
|
9
|
+
# Main orchestrator for processing Ruby files
|
|
10
|
+
# Finds import blocks, sorts them, and reconstructs the file
|
|
11
|
+
class FileProcessor
|
|
12
|
+
SKIP_FILE_PATTERN = /^#\s*isort:\s*skip_file\b/i.freeze
|
|
13
|
+
SKIP_LINE_PATTERN = /#\s*isort:\s*skip\b/i.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(file_path, options = {})
|
|
16
|
+
@file_path = file_path
|
|
17
|
+
@options = {
|
|
18
|
+
check: false,
|
|
19
|
+
diff: false,
|
|
20
|
+
atomic: false,
|
|
21
|
+
quiet: false,
|
|
22
|
+
verbose: false
|
|
23
|
+
}.merge(options)
|
|
24
|
+
@parser = Parser.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Process the file - returns true if changes were made
|
|
28
|
+
def process
|
|
29
|
+
original_content = read_file_content
|
|
30
|
+
return false if original_content.nil? || original_content.empty?
|
|
31
|
+
|
|
32
|
+
# Check for skip_file directive
|
|
33
|
+
check_skip_file_directive!(original_content)
|
|
34
|
+
|
|
35
|
+
# Atomic mode: validate original syntax first
|
|
36
|
+
if @options[:atomic]
|
|
37
|
+
validate_original_syntax!(original_content)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
lines = parse_lines(original_content)
|
|
41
|
+
return false if lines.empty?
|
|
42
|
+
|
|
43
|
+
# Find all import blocks in the file
|
|
44
|
+
blocks_with_positions = find_import_blocks(lines)
|
|
45
|
+
return false if blocks_with_positions.empty?
|
|
46
|
+
|
|
47
|
+
# Sort each block
|
|
48
|
+
blocks_with_positions.each do |block_info|
|
|
49
|
+
block_info[:block].sort_and_dedupe!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reconstruct the file with sorted blocks
|
|
53
|
+
new_content = reconstruct_file(lines, blocks_with_positions)
|
|
54
|
+
|
|
55
|
+
# No changes needed
|
|
56
|
+
return false if new_content == original_content
|
|
57
|
+
|
|
58
|
+
# Atomic mode: validate new syntax before writing
|
|
59
|
+
if @options[:atomic]
|
|
60
|
+
validate_new_syntax!(new_content)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Write the file
|
|
64
|
+
write_file(new_content)
|
|
65
|
+
true
|
|
66
|
+
rescue ArgumentError => e
|
|
67
|
+
# Handle encoding errors from strip/other string operations
|
|
68
|
+
if e.message.include?("invalid byte sequence")
|
|
69
|
+
raise Encoding::CompatibilityError, "Invalid encoding in #{@file_path}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if file would change (dry-run mode for --check)
|
|
75
|
+
# Returns true if file would be modified, false otherwise
|
|
76
|
+
def check
|
|
77
|
+
original_content = read_file_content
|
|
78
|
+
return false if original_content.nil? || original_content.empty?
|
|
79
|
+
|
|
80
|
+
# Check for skip_file directive
|
|
81
|
+
begin
|
|
82
|
+
check_skip_file_directive!(original_content)
|
|
83
|
+
rescue FileSkipped
|
|
84
|
+
return false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
lines = parse_lines(original_content)
|
|
88
|
+
return false if lines.empty?
|
|
89
|
+
|
|
90
|
+
blocks_with_positions = find_import_blocks(lines)
|
|
91
|
+
return false if blocks_with_positions.empty?
|
|
92
|
+
|
|
93
|
+
# Sort each block (on a copy)
|
|
94
|
+
blocks_with_positions.each do |block_info|
|
|
95
|
+
block_info[:block].sort_and_dedupe!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
new_content = reconstruct_file(lines, blocks_with_positions)
|
|
99
|
+
new_content != original_content
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get diff of changes without applying (for --diff mode)
|
|
103
|
+
# Returns diff string or nil if no changes
|
|
104
|
+
def diff
|
|
105
|
+
original_content = read_file_content
|
|
106
|
+
return nil if original_content.nil? || original_content.empty?
|
|
107
|
+
|
|
108
|
+
# Check for skip_file directive
|
|
109
|
+
begin
|
|
110
|
+
check_skip_file_directive!(original_content)
|
|
111
|
+
rescue FileSkipped
|
|
112
|
+
return nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
lines = parse_lines(original_content)
|
|
116
|
+
return nil if lines.empty?
|
|
117
|
+
|
|
118
|
+
blocks_with_positions = find_import_blocks(lines)
|
|
119
|
+
return nil if blocks_with_positions.empty?
|
|
120
|
+
|
|
121
|
+
# Sort each block (on a copy)
|
|
122
|
+
blocks_with_positions.each do |block_info|
|
|
123
|
+
block_info[:block].sort_and_dedupe!
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
new_content = reconstruct_file(lines, blocks_with_positions)
|
|
127
|
+
return nil if new_content == original_content
|
|
128
|
+
|
|
129
|
+
generate_diff(original_content, new_content)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def read_file_content
|
|
135
|
+
content = File.read(@file_path, encoding: "UTF-8")
|
|
136
|
+
|
|
137
|
+
unless content.valid_encoding?
|
|
138
|
+
raise Encoding::CompatibilityError, "Invalid encoding in #{@file_path}: contains invalid UTF-8 bytes"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Normalize line endings
|
|
142
|
+
content.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
143
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
|
|
144
|
+
raise Encoding::CompatibilityError, "Invalid encoding in #{@file_path}: #{e.message}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_lines(content)
|
|
148
|
+
content.lines(chomp: false).map { |line| line }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_skip_file_directive!(content)
|
|
152
|
+
# Check first 50 lines for skip_file directive
|
|
153
|
+
content.lines.first(50).each do |line|
|
|
154
|
+
if line.match?(SKIP_FILE_PATTERN)
|
|
155
|
+
raise FileSkipped.new(@file_path, "isort:skip_file directive")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_original_syntax!(content)
|
|
161
|
+
unless SyntaxValidator.valid?(content)
|
|
162
|
+
raise ExistingSyntaxErrors.new(@file_path)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_new_syntax!(content)
|
|
167
|
+
unless SyntaxValidator.valid?(content)
|
|
168
|
+
raise IntroducedSyntaxErrors.new(@file_path)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def generate_diff(original, modified)
|
|
173
|
+
require "tempfile"
|
|
174
|
+
|
|
175
|
+
original_file = Tempfile.new(["original", ".rb"])
|
|
176
|
+
modified_file = Tempfile.new(["modified", ".rb"])
|
|
177
|
+
|
|
178
|
+
begin
|
|
179
|
+
original_file.write(original)
|
|
180
|
+
original_file.flush
|
|
181
|
+
modified_file.write(modified)
|
|
182
|
+
modified_file.flush
|
|
183
|
+
|
|
184
|
+
# Use unified diff format
|
|
185
|
+
diff_output = `diff -u "#{original_file.path}" "#{modified_file.path}" 2>/dev/null`
|
|
186
|
+
|
|
187
|
+
# Replace temp file paths with actual file path in diff header
|
|
188
|
+
diff_output = diff_output.gsub(original_file.path, "#{@file_path} (original)")
|
|
189
|
+
diff_output = diff_output.gsub(modified_file.path, "#{@file_path} (sorted)")
|
|
190
|
+
|
|
191
|
+
diff_output.empty? ? nil : diff_output
|
|
192
|
+
ensure
|
|
193
|
+
original_file.close
|
|
194
|
+
original_file.unlink
|
|
195
|
+
modified_file.close
|
|
196
|
+
modified_file.unlink
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def write_file(content)
|
|
201
|
+
File.write(@file_path, content)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Find all import blocks in the file
|
|
205
|
+
# Returns array of { block: ImportBlock, start_line: Int, end_line: Int }
|
|
206
|
+
def find_import_blocks(lines)
|
|
207
|
+
blocks = []
|
|
208
|
+
current_block = nil
|
|
209
|
+
pending_comments = []
|
|
210
|
+
pending_blanks = []
|
|
211
|
+
in_multiline_string = false
|
|
212
|
+
heredoc_delimiter = nil
|
|
213
|
+
|
|
214
|
+
lines.each_with_index do |line, index|
|
|
215
|
+
line_num = index + 1
|
|
216
|
+
|
|
217
|
+
# Handle potential encoding issues in line processing
|
|
218
|
+
begin
|
|
219
|
+
stripped = line.to_s.strip
|
|
220
|
+
rescue ArgumentError
|
|
221
|
+
# If we can't process the line, treat it as code
|
|
222
|
+
finalize_block(current_block, blocks, index, pending_blanks)
|
|
223
|
+
current_block = nil
|
|
224
|
+
pending_comments = []
|
|
225
|
+
pending_blanks = []
|
|
226
|
+
next
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
indentation = @parser.extract_indentation(line)
|
|
230
|
+
|
|
231
|
+
# Track heredoc state
|
|
232
|
+
if heredoc_delimiter
|
|
233
|
+
heredoc_delimiter = nil if stripped == heredoc_delimiter
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Check for heredoc start
|
|
238
|
+
if (heredoc_match = line.match(/<<[-~]?['"]?(\w+)['"]?/))
|
|
239
|
+
heredoc_delimiter = heredoc_match[1]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Skip multiline strings (basic detection)
|
|
243
|
+
if in_multiline_string
|
|
244
|
+
in_multiline_string = false if stripped.end_with?('"""') || stripped.end_with?("'''")
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if stripped.start_with?('"""') || stripped.start_with?("'''")
|
|
249
|
+
in_multiline_string = true unless stripped.count(stripped[0..2]) >= 2
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
line_type = @parser.classify_line(line, line_number: line_num)
|
|
254
|
+
|
|
255
|
+
case line_type
|
|
256
|
+
when :shebang, :magic_comment
|
|
257
|
+
# These stay in place, finalize any current block
|
|
258
|
+
finalize_block(current_block, blocks, index - 1, pending_blanks)
|
|
259
|
+
current_block = nil
|
|
260
|
+
pending_comments = []
|
|
261
|
+
pending_blanks = []
|
|
262
|
+
|
|
263
|
+
when :comment
|
|
264
|
+
# Accumulate comments - they might belong to the next import
|
|
265
|
+
# But only if there's no blank line separating them
|
|
266
|
+
if pending_blanks.empty?
|
|
267
|
+
pending_comments << line
|
|
268
|
+
else
|
|
269
|
+
# There was a blank line before this comment, so previous comments
|
|
270
|
+
# are "floating" and don't belong to imports - clear them
|
|
271
|
+
pending_comments = [line]
|
|
272
|
+
pending_blanks = []
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
when :blank
|
|
276
|
+
if current_block && !current_block.empty?
|
|
277
|
+
# Track blank lines - more than 1 consecutive ends the block
|
|
278
|
+
pending_blanks << line
|
|
279
|
+
if pending_blanks.size > 1
|
|
280
|
+
# End current block (don't include the blank lines in the block)
|
|
281
|
+
# The blank lines will remain between blocks in reconstruction
|
|
282
|
+
finalize_block(current_block, blocks, index - pending_blanks.size, [])
|
|
283
|
+
current_block = nil
|
|
284
|
+
pending_comments = []
|
|
285
|
+
# DON'T carry over pending_blanks - they stay as separators between blocks
|
|
286
|
+
pending_blanks = []
|
|
287
|
+
end
|
|
288
|
+
else
|
|
289
|
+
# Not in a block - blank line separates any pending comments from next import
|
|
290
|
+
if pending_comments.any?
|
|
291
|
+
# Comments followed by blank line are "floating" - don't attach to next import
|
|
292
|
+
pending_comments = []
|
|
293
|
+
end
|
|
294
|
+
pending_blanks << line
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
when :require, :require_relative, :include, :extend, :autoload, :using
|
|
298
|
+
# Reset pending blanks when we see an import (they're part of the block)
|
|
299
|
+
if current_block && !pending_blanks.empty?
|
|
300
|
+
# Single blank line between imports is OK, attach to import's comments
|
|
301
|
+
pending_comments = pending_blanks + pending_comments
|
|
302
|
+
pending_blanks = []
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# This is an import line!
|
|
306
|
+
if current_block.nil?
|
|
307
|
+
current_block = ImportBlock.new(indentation: indentation)
|
|
308
|
+
# The block starts at the first pending comment or this line
|
|
309
|
+
if pending_comments.any? || pending_blanks.any?
|
|
310
|
+
current_block.start_line = index - pending_comments.size - pending_blanks.size
|
|
311
|
+
current_block.leading_content = pending_blanks.dup
|
|
312
|
+
else
|
|
313
|
+
current_block.start_line = index
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Handle indentation change - might be a new block
|
|
318
|
+
if current_block.indentation != indentation && !current_block.empty?
|
|
319
|
+
# Different indentation, finalize current block and start new one
|
|
320
|
+
finalize_block(current_block, blocks, index - 1 - pending_comments.size, [])
|
|
321
|
+
|
|
322
|
+
current_block = ImportBlock.new(indentation: indentation)
|
|
323
|
+
if pending_comments.any? || pending_blanks.any?
|
|
324
|
+
current_block.start_line = index - pending_comments.size - pending_blanks.size
|
|
325
|
+
current_block.leading_content = pending_blanks.dup
|
|
326
|
+
else
|
|
327
|
+
current_block.start_line = index
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Create the import statement with its leading comments
|
|
332
|
+
statement = ImportStatement.new(
|
|
333
|
+
raw_line: line,
|
|
334
|
+
type: line_type,
|
|
335
|
+
leading_comments: pending_comments.dup,
|
|
336
|
+
indentation: indentation
|
|
337
|
+
)
|
|
338
|
+
current_block.add_statement(statement)
|
|
339
|
+
|
|
340
|
+
pending_comments = []
|
|
341
|
+
pending_blanks = []
|
|
342
|
+
current_block.end_line = index
|
|
343
|
+
|
|
344
|
+
when :code
|
|
345
|
+
# Non-import code - finalize any current block
|
|
346
|
+
finalize_block(current_block, blocks, index - 1, pending_blanks)
|
|
347
|
+
current_block = nil
|
|
348
|
+
pending_comments = []
|
|
349
|
+
pending_blanks = []
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Finalize any remaining block at end of file
|
|
354
|
+
# Include trailing comments if they exist
|
|
355
|
+
if current_block && !current_block.empty?
|
|
356
|
+
# Add any trailing comments to the last statement
|
|
357
|
+
if pending_comments.any?
|
|
358
|
+
# These are orphan comments at end - don't include in block range
|
|
359
|
+
current_block.end_line = lines.size - 1 - pending_blanks.size - pending_comments.size
|
|
360
|
+
else
|
|
361
|
+
current_block.end_line = lines.size - 1 - pending_blanks.size
|
|
362
|
+
end
|
|
363
|
+
blocks << { block: current_block, start_line: current_block.start_line, end_line: current_block.end_line }
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
blocks
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def finalize_block(block, blocks, end_index, pending_blanks)
|
|
370
|
+
return unless block && !block.empty?
|
|
371
|
+
|
|
372
|
+
block.end_line = [end_index - pending_blanks.size, block.start_line].max
|
|
373
|
+
blocks << { block: block, start_line: block.start_line, end_line: block.end_line }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Reconstruct the file with sorted import blocks
|
|
377
|
+
def reconstruct_file(original_lines, blocks_with_positions)
|
|
378
|
+
return original_lines.join if blocks_with_positions.empty?
|
|
379
|
+
|
|
380
|
+
result = []
|
|
381
|
+
last_end = -1
|
|
382
|
+
|
|
383
|
+
# Sort blocks by start position
|
|
384
|
+
sorted_blocks = blocks_with_positions.sort_by { |b| b[:start_line] }
|
|
385
|
+
|
|
386
|
+
sorted_blocks.each_with_index do |block_info, block_index|
|
|
387
|
+
start_line = block_info[:start_line]
|
|
388
|
+
end_line = block_info[:end_line]
|
|
389
|
+
block = block_info[:block]
|
|
390
|
+
|
|
391
|
+
# Add lines before this block (from last_end+1 to start_line-1)
|
|
392
|
+
if start_line > last_end + 1
|
|
393
|
+
lines_between = (last_end + 1...start_line).map { |i| original_lines[i] }.compact
|
|
394
|
+
|
|
395
|
+
# If lines between consecutive import blocks are only blank lines,
|
|
396
|
+
# normalize to a single blank line
|
|
397
|
+
if block_index > 0 && lines_between.all? { |l| l.strip.empty? }
|
|
398
|
+
# Add single blank line if there were any blanks
|
|
399
|
+
result << "\n" unless lines_between.empty?
|
|
400
|
+
else
|
|
401
|
+
# Add all lines as-is
|
|
402
|
+
lines_between.each { |line| result << line }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Add the sorted block
|
|
407
|
+
sorted_lines = block.to_lines
|
|
408
|
+
result.concat(sorted_lines)
|
|
409
|
+
|
|
410
|
+
last_end = end_line
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Add remaining lines after the last block
|
|
414
|
+
if last_end < original_lines.size - 1
|
|
415
|
+
((last_end + 1)...original_lines.size).each do |i|
|
|
416
|
+
result << original_lines[i] if original_lines[i]
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Join and ensure trailing newline
|
|
421
|
+
content = result.join
|
|
422
|
+
content = "#{content.rstrip}\n" unless content.empty?
|
|
423
|
+
content
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "import_statement"
|
|
4
|
+
|
|
5
|
+
module Isort
|
|
6
|
+
# Represents a contiguous block of import statements
|
|
7
|
+
# Handles sorting, deduplication, and reconstruction
|
|
8
|
+
class ImportBlock
|
|
9
|
+
attr_reader :statements, :indentation
|
|
10
|
+
attr_accessor :start_line, :end_line, :leading_content, :trailing_blank_lines
|
|
11
|
+
|
|
12
|
+
def initialize(indentation: "")
|
|
13
|
+
@statements = []
|
|
14
|
+
@start_line = nil
|
|
15
|
+
@end_line = nil
|
|
16
|
+
@indentation = indentation
|
|
17
|
+
@leading_content = [] # Comments/blanks before first import in block
|
|
18
|
+
@trailing_blank_lines = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_statement(statement)
|
|
22
|
+
@statements << statement
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty?
|
|
26
|
+
@statements.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def size
|
|
30
|
+
@statements.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sort statements by type then alphabetically, and remove duplicates
|
|
34
|
+
# Imports with isort:skip directive stay in their original position
|
|
35
|
+
def sort_and_dedupe!
|
|
36
|
+
return if @statements.empty?
|
|
37
|
+
|
|
38
|
+
# Separate skipped and sortable statements
|
|
39
|
+
skipped_with_positions = []
|
|
40
|
+
sortable = []
|
|
41
|
+
|
|
42
|
+
@statements.each_with_index do |stmt, index|
|
|
43
|
+
if stmt.skip_sorting?
|
|
44
|
+
skipped_with_positions << { statement: stmt, position: index }
|
|
45
|
+
else
|
|
46
|
+
sortable << stmt
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Sort only the sortable statements
|
|
51
|
+
sortable.sort!
|
|
52
|
+
|
|
53
|
+
# Remove duplicates from sortable (keep first occurrence with its comments)
|
|
54
|
+
seen = {}
|
|
55
|
+
sortable = sortable.reject do |stmt|
|
|
56
|
+
key = stmt.normalized_key
|
|
57
|
+
if seen[key]
|
|
58
|
+
true # Remove this duplicate
|
|
59
|
+
else
|
|
60
|
+
seen[key] = true
|
|
61
|
+
false # Keep this one
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# If no skipped statements, just use the sorted list
|
|
66
|
+
if skipped_with_positions.empty?
|
|
67
|
+
@statements = sortable
|
|
68
|
+
return self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Re-insert skipped statements at their original relative positions
|
|
72
|
+
# We need to calculate where each skipped statement should go
|
|
73
|
+
# in the new sorted list based on its original position ratio
|
|
74
|
+
result = sortable.dup
|
|
75
|
+
|
|
76
|
+
# Sort skipped statements by their original position
|
|
77
|
+
skipped_with_positions.sort_by! { |s| s[:position] }
|
|
78
|
+
|
|
79
|
+
# Insert each skipped statement
|
|
80
|
+
skipped_with_positions.each_with_index do |skipped_info, skip_idx|
|
|
81
|
+
original_pos = skipped_info[:position]
|
|
82
|
+
original_count = @statements.size
|
|
83
|
+
|
|
84
|
+
# Calculate the relative position in the new array
|
|
85
|
+
if original_count <= 1
|
|
86
|
+
insert_pos = skip_idx
|
|
87
|
+
else
|
|
88
|
+
# Scale the position to the new array size
|
|
89
|
+
ratio = original_pos.to_f / (original_count - 1)
|
|
90
|
+
new_max = result.size + skip_idx
|
|
91
|
+
insert_pos = (ratio * new_max).round
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clamp to valid range
|
|
95
|
+
insert_pos = [[insert_pos, 0].max, result.size].min
|
|
96
|
+
|
|
97
|
+
result.insert(insert_pos, skipped_info[:statement])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@statements = result
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Convert the sorted block back to lines
|
|
105
|
+
def to_lines
|
|
106
|
+
return [] if @statements.empty?
|
|
107
|
+
|
|
108
|
+
result = []
|
|
109
|
+
|
|
110
|
+
# Add any leading content (comments before first import)
|
|
111
|
+
result.concat(@leading_content) unless @leading_content.empty?
|
|
112
|
+
|
|
113
|
+
# Group statements by section and type for proper spacing
|
|
114
|
+
current_section = nil
|
|
115
|
+
current_type = nil
|
|
116
|
+
|
|
117
|
+
@statements.each do |stmt|
|
|
118
|
+
# Filter out blank lines from the statement's leading comments
|
|
119
|
+
# (we'll add our own spacing between groups)
|
|
120
|
+
non_blank_comments = stmt.leading_comments.reject { |c| c.strip.empty? }
|
|
121
|
+
|
|
122
|
+
# Add blank line between different sections (highest priority separator)
|
|
123
|
+
if current_section && current_section != stmt.section && !result.empty?
|
|
124
|
+
# Add blank line between sections (unless last line is already blank)
|
|
125
|
+
unless result.last&.strip&.empty?
|
|
126
|
+
result << "#{@indentation}\n"
|
|
127
|
+
end
|
|
128
|
+
# Add blank line between different import types within the same section
|
|
129
|
+
elsif current_type && current_type != stmt.type && current_section == stmt.section && !result.empty?
|
|
130
|
+
# Only add blank line if last line isn't already blank
|
|
131
|
+
unless result.last&.strip&.empty?
|
|
132
|
+
result << "#{@indentation}\n"
|
|
133
|
+
end
|
|
134
|
+
elsif current_type && current_type == stmt.type && non_blank_comments.any?
|
|
135
|
+
# Same type but has comments - might need blank line before the comment
|
|
136
|
+
# Only add if last line isn't blank
|
|
137
|
+
unless result.last&.strip&.empty?
|
|
138
|
+
result << "#{@indentation}\n" if stmt.leading_comments.any? { |c| c.strip.empty? }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Add non-blank leading comments
|
|
143
|
+
non_blank_comments.each do |line|
|
|
144
|
+
result << line
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Add the import line itself
|
|
148
|
+
result << stmt.raw_line
|
|
149
|
+
|
|
150
|
+
current_section = stmt.section
|
|
151
|
+
current_type = stmt.type
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Calculate how many original lines this block spans
|
|
158
|
+
def original_line_count
|
|
159
|
+
return 0 if @start_line.nil? || @end_line.nil?
|
|
160
|
+
|
|
161
|
+
@end_line - @start_line + 1
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|