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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0b54367ec2e05c1827acb9d80bdc606634b858a98530ccf7c05d0e196269918
4
- data.tar.gz: 4a7ab81c94061b440edd1f63cfc7569376278c4df39aab331bc84f70c2508ec5
3
+ metadata.gz: a833de16ba9dbfd28c133288adf5a2a2ad107e1e5932db200d479b412165d133
4
+ data.tar.gz: 5b36d59223838c1c3518b3cb8f2f3c25171e3a296f3b3bb3f9504c58c75e18ca
5
5
  SHA512:
6
- metadata.gz: ada7d48c391b8a3e168a8ec8e3bbfb8579312a605789499f1a89188b5f5bd8af45e308ac1d8663c6f4b0c8b743a0c6d1e9b3c18e6072abb94c887cef14b72d66
7
- data.tar.gz: a1f3ad1c5d95b411dd3292a8c452357f6e81472ace0d686fcd30cfe792a83b7b1dc79d3ef01d6b1bfc0a337fb6edd0057e60560bc769029018c3b7f0e4d1edf9
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