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.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module Isort
6
+ # Represents a single import statement with all its metadata
7
+ # This includes the raw line, type, sort key, and associated comments
8
+ class ImportStatement
9
+ IMPORT_TYPES = %i[require require_relative include extend autoload using].freeze
10
+ TYPE_ORDER = {
11
+ require: 0,
12
+ require_relative: 1,
13
+ include: 2,
14
+ extend: 3,
15
+ autoload: 4,
16
+ using: 5
17
+ }.freeze
18
+
19
+ SKIP_PATTERN = /#\s*isort:\s*skip\b/i.freeze
20
+
21
+ attr_reader :type, :raw_line, :sort_key, :leading_comments, :indentation
22
+ attr_reader :skip_sorting, :section
23
+
24
+ def initialize(raw_line:, type:, leading_comments: [], indentation: "")
25
+ @raw_line = raw_line
26
+ @type = type
27
+ @leading_comments = leading_comments
28
+ @indentation = indentation
29
+ @sort_key = extract_sort_key
30
+ @skip_sorting = has_skip_directive?
31
+ @section = Section.classify(self)
32
+ end
33
+
34
+ # Check if this import has an isort:skip directive
35
+ def has_skip_directive?
36
+ @raw_line.match?(SKIP_PATTERN)
37
+ end
38
+
39
+ alias skip_sorting? skip_sorting
40
+
41
+ # Returns the full representation including leading comments
42
+ def to_s
43
+ lines = []
44
+ lines.concat(@leading_comments) unless @leading_comments.empty?
45
+ lines << @raw_line
46
+ lines.join
47
+ end
48
+
49
+ # Returns lines as an array (for reconstruction)
50
+ def to_lines
51
+ result = @leading_comments.dup
52
+ result << @raw_line
53
+ result
54
+ end
55
+
56
+ # For deduplication - normalized import path/module
57
+ def normalized_key
58
+ @normalized_key ||= begin
59
+ stripped = @raw_line.strip
60
+ # Extract the actual import value for comparison
61
+ case @type
62
+ when :require, :require_relative
63
+ # Extract path from require 'path' or require "path"
64
+ if (match = stripped.match(/^(?:require|require_relative)\s+['"]([^'"]+)['"]/))
65
+ "#{@type}:#{match[1]}"
66
+ else
67
+ "#{@type}:#{stripped}"
68
+ end
69
+ when :include, :extend, :using
70
+ # Extract module name
71
+ if (match = stripped.match(/^(?:include|extend|using)\s+(\S+)/))
72
+ "#{@type}:#{match[1]}"
73
+ else
74
+ "#{@type}:#{stripped}"
75
+ end
76
+ when :autoload
77
+ # Extract constant and path
78
+ if (match = stripped.match(/^autoload\s+:?(\w+)/))
79
+ "#{@type}:#{match[1]}"
80
+ else
81
+ "#{@type}:#{stripped}"
82
+ end
83
+ else
84
+ "#{@type}:#{stripped}"
85
+ end
86
+ end
87
+ end
88
+
89
+ # Compare for sorting - first by section, then by type order, then alphabetically
90
+ def <=>(other)
91
+ # First compare by section (stdlib, thirdparty, firstparty, localfolder)
92
+ section_comparison = Section.order(@section) <=> Section.order(other.section)
93
+ return section_comparison unless section_comparison.zero?
94
+
95
+ # Then by type within section
96
+ type_comparison = TYPE_ORDER[@type] <=> TYPE_ORDER[other.type]
97
+ return type_comparison unless type_comparison.zero?
98
+
99
+ # Finally alphabetically
100
+ @sort_key <=> other.sort_key
101
+ end
102
+
103
+ private
104
+
105
+ def extract_sort_key
106
+ stripped = @raw_line.strip
107
+ # Remove the keyword and extract the sortable part
108
+ case @type
109
+ when :require
110
+ stripped.sub(/^require\s+/, "")
111
+ when :require_relative
112
+ stripped.sub(/^require_relative\s+/, "")
113
+ when :include
114
+ stripped.sub(/^include\s+/, "")
115
+ when :extend
116
+ stripped.sub(/^extend\s+/, "")
117
+ when :autoload
118
+ stripped.sub(/^autoload\s+/, "")
119
+ when :using
120
+ stripped.sub(/^using\s+/, "")
121
+ else
122
+ stripped
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Isort
4
+ # Parses Ruby source lines and classifies them by type
5
+ # This is the core component for understanding file structure
6
+ class Parser
7
+ # Line types that can be returned
8
+ LINE_TYPES = %i[
9
+ shebang
10
+ magic_comment
11
+ require
12
+ require_relative
13
+ include
14
+ extend
15
+ autoload
16
+ using
17
+ comment
18
+ blank
19
+ code
20
+ ].freeze
21
+
22
+ IMPORT_TYPES = %i[require require_relative include extend autoload using].freeze
23
+
24
+ # Magic comment patterns (encoding, frozen_string_literal, etc.)
25
+ MAGIC_COMMENT_PATTERN = /^#\s*(?:encoding|coding|frozen_string_literal|warn_indent|shareable_constant_value):/i
26
+
27
+ # Skip directive patterns
28
+ SKIP_LINE_PATTERN = /#\s*isort:\s*skip\b/i.freeze
29
+ SKIP_FILE_PATTERN = /^#\s*isort:\s*skip_file\b/i.freeze
30
+
31
+ def initialize
32
+ @line_number = 0
33
+ end
34
+
35
+ # Classify a single line and return its type
36
+ def classify_line(line, line_number: nil)
37
+ @line_number = line_number if line_number
38
+ stripped = line.to_s.strip
39
+
40
+ return :blank if stripped.empty?
41
+ return classify_comment(line, stripped) if stripped.start_with?("#")
42
+
43
+ classify_code(stripped)
44
+ end
45
+
46
+ # Check if a line type is an import
47
+ def import_type?(type)
48
+ IMPORT_TYPES.include?(type)
49
+ end
50
+
51
+ # Extract the indentation from a line
52
+ def extract_indentation(line)
53
+ match = line.to_s.match(/^(\s*)/)
54
+ match ? match[1] : ""
55
+ end
56
+
57
+ # Check if line is a shebang (must be line 1)
58
+ def shebang?(line, line_number)
59
+ line_number == 1 && line.to_s.strip.start_with?("#!")
60
+ end
61
+
62
+ # Check if line has an isort:skip directive
63
+ def has_skip_directive?(line)
64
+ line.to_s.match?(SKIP_LINE_PATTERN) && !line.to_s.match?(SKIP_FILE_PATTERN)
65
+ end
66
+
67
+ # Check if line has an isort:skip_file directive
68
+ def has_skip_file_directive?(line)
69
+ line.to_s.match?(SKIP_FILE_PATTERN)
70
+ end
71
+
72
+ private
73
+
74
+ def classify_comment(line, stripped)
75
+ # Check for shebang on first line
76
+ return :shebang if @line_number == 1 && stripped.start_with?("#!")
77
+
78
+ # Check for magic comments (only valid at top of file, but we classify anyway)
79
+ return :magic_comment if stripped.match?(MAGIC_COMMENT_PATTERN)
80
+
81
+ :comment
82
+ end
83
+
84
+ def classify_code(stripped)
85
+ # Skip lines that are primarily string literals containing import keywords
86
+ # e.g., puts "require 'json'" or error_msg = 'include Module'
87
+ return :code if string_containing_import_keyword?(stripped)
88
+
89
+ # Order matters - check more specific patterns first
90
+
91
+ # require_relative must come before require
92
+ return :require_relative if require_relative_line?(stripped)
93
+ return :require if require_line?(stripped)
94
+ return :include if include_line?(stripped)
95
+ return :extend if extend_line?(stripped)
96
+ return :autoload if autoload_line?(stripped)
97
+ return :using if using_line?(stripped)
98
+
99
+ :code
100
+ end
101
+
102
+ # Detect if a line is primarily a string that happens to contain import keywords
103
+ # This prevents false positives like: puts "require 'json'" or x = "include Foo"
104
+ def string_containing_import_keyword?(stripped)
105
+ # If line starts with a string assignment or method call with string arg
106
+ # and the import keyword appears inside quotes, it's not a real import
107
+
108
+ # Pattern: variable = "...require..." or variable = '...require...'
109
+ return true if stripped.match?(/^\w+\s*=\s*['"].*(?:require|require_relative|include|extend|autoload|using).*['"]/)
110
+
111
+ # Pattern: method_call "...require..." or method_call '...require...'
112
+ # e.g., puts "require 'json'"
113
+ return true if stripped.match?(/^\w+\s+['"].*(?:require|require_relative|include|extend|autoload|using).*['"]/)
114
+
115
+ # Pattern: method_call("...require...") - method with parens and string arg
116
+ return true if stripped.match?(/^\w+\(["'].*(?:require|require_relative|include|extend|autoload|using).*["']\)/)
117
+
118
+ # Check if import keyword appears after an opening quote (inside a string)
119
+ # This catches cases like: desc "require the json gem"
120
+ if stripped.include?('"') || stripped.include?("'")
121
+ # Find position of first quote and import keyword
122
+ first_quote_pos = [stripped.index('"'), stripped.index("'")].compact.min
123
+ import_keywords = %w[require require_relative include extend autoload using]
124
+
125
+ import_keywords.each do |keyword|
126
+ keyword_pos = stripped.index(keyword)
127
+ next unless keyword_pos
128
+
129
+ # If keyword appears after a quote, it might be inside a string
130
+ # But we need to ensure it's not at the start (real import)
131
+ if first_quote_pos && keyword_pos > first_quote_pos && keyword_pos > 0
132
+ return true
133
+ end
134
+ end
135
+ end
136
+
137
+ false
138
+ end
139
+
140
+ def require_line?(stripped)
141
+ # Match: require 'foo' or require "foo" or require('foo')
142
+ # But NOT: require_relative
143
+ stripped.match?(/^require\s+['"]/) || stripped.match?(/^require\(['"]/)
144
+ end
145
+
146
+ def require_relative_line?(stripped)
147
+ # Match: require_relative 'foo' or require_relative "foo"
148
+ stripped.match?(/^require_relative\s+['"]/) || stripped.match?(/^require_relative\(['"]/)
149
+ end
150
+
151
+ def include_line?(stripped)
152
+ # Match: include ModuleName or include(ModuleName)
153
+ # But NOT: included, includes, include?
154
+ stripped.match?(/^include\s+[A-Z]/) || stripped.match?(/^include\([A-Z]/)
155
+ end
156
+
157
+ def extend_line?(stripped)
158
+ # Match: extend ModuleName
159
+ # But NOT: extended, extends
160
+ stripped.match?(/^extend\s+[A-Z]/) || stripped.match?(/^extend\([A-Z]/)
161
+ end
162
+
163
+ def autoload_line?(stripped)
164
+ # Match: autoload :Constant, 'path' or autoload(:Constant, 'path')
165
+ stripped.match?(/^autoload\s+:/) || stripped.match?(/^autoload\(:/)
166
+ end
167
+
168
+ def using_line?(stripped)
169
+ # Match: using ModuleName
170
+ stripped.match?(/^using\s+[A-Z]/) || stripped.match?(/^using\([A-Z]/)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Isort
6
+ # Categorizes imports into sections based on their source
7
+ # - STDLIB: Ruby standard library
8
+ # - THIRDPARTY: Gems/external packages
9
+ # - FIRSTPARTY: Project's own modules
10
+ # - LOCALFOLDER: Relative imports
11
+ class Section
12
+ SECTIONS = %i[stdlib thirdparty firstparty localfolder].freeze
13
+
14
+ SECTION_ORDER = {
15
+ stdlib: 0,
16
+ thirdparty: 1,
17
+ firstparty: 2,
18
+ localfolder: 3
19
+ }.freeze
20
+
21
+ # Ruby standard library modules (partial list of common ones)
22
+ # This list covers the most commonly used stdlib modules
23
+ STDLIB_MODULES = Set.new(%w[
24
+ abbrev
25
+ base64
26
+ benchmark
27
+ bigdecimal
28
+ cgi
29
+ csv
30
+ date
31
+ delegate
32
+ digest
33
+ drb
34
+ english
35
+ erb
36
+ etc
37
+ fcntl
38
+ fiddle
39
+ fileutils
40
+ find
41
+ forwardable
42
+ getoptlong
43
+ io/console
44
+ io/nonblock
45
+ io/wait
46
+ ipaddr
47
+ irb
48
+ json
49
+ logger
50
+ matrix
51
+ minitest
52
+ monitor
53
+ mutex_m
54
+ net/ftp
55
+ net/http
56
+ net/https
57
+ net/imap
58
+ net/pop
59
+ net/smtp
60
+ nkf
61
+ objspace
62
+ observer
63
+ open-uri
64
+ open3
65
+ openssl
66
+ optparse
67
+ ostruct
68
+ pathname
69
+ pp
70
+ prettyprint
71
+ prime
72
+ pstore
73
+ psych
74
+ racc
75
+ rake
76
+ rdoc
77
+ readline
78
+ resolv
79
+ ripper
80
+ rss
81
+ securerandom
82
+ set
83
+ shellwords
84
+ singleton
85
+ socket
86
+ stringio
87
+ strscan
88
+ syslog
89
+ tempfile
90
+ time
91
+ timeout
92
+ tmpdir
93
+ tracer
94
+ tsort
95
+ un
96
+ uri
97
+ weakref
98
+ webrick
99
+ yaml
100
+ zlib
101
+ ]).freeze
102
+
103
+ class << self
104
+ # Classify an import statement into a section
105
+ def classify(statement)
106
+ case statement.type
107
+ when :require
108
+ classify_require(statement)
109
+ when :require_relative
110
+ :localfolder
111
+ when :include, :extend, :using
112
+ classify_module(statement)
113
+ when :autoload
114
+ :firstparty # autoload is typically project-specific
115
+ else
116
+ :thirdparty
117
+ end
118
+ end
119
+
120
+ # Get the section order for sorting
121
+ def order(section)
122
+ SECTION_ORDER[section] || 999
123
+ end
124
+
125
+ private
126
+
127
+ def classify_require(statement)
128
+ # Extract the require path
129
+ stripped = statement.raw_line.strip
130
+ path = nil
131
+
132
+ if (match = stripped.match(/^require\s+['"]([^'"]+)['"]/))
133
+ path = match[1]
134
+ elsif (match = stripped.match(/^require\(['"]([^'"]+)['"]\)/))
135
+ path = match[1]
136
+ end
137
+
138
+ return :thirdparty unless path
139
+
140
+ # Check if it's a stdlib module
141
+ if stdlib_module?(path)
142
+ :stdlib
143
+ else
144
+ :thirdparty
145
+ end
146
+ end
147
+
148
+ def classify_module(statement)
149
+ # Include, extend, using are typically project-specific
150
+ # unless they're from well-known gems
151
+ stripped = statement.raw_line.strip
152
+
153
+ # Extract module name
154
+ module_name = nil
155
+ if (match = stripped.match(/^(?:include|extend|using)\s+(\S+)/))
156
+ module_name = match[1]
157
+ end
158
+
159
+ return :firstparty unless module_name
160
+
161
+ # Check for known stdlib modules
162
+ if stdlib_constant?(module_name)
163
+ :stdlib
164
+ else
165
+ :firstparty
166
+ end
167
+ end
168
+
169
+ def stdlib_module?(path)
170
+ # Remove file extension if present
171
+ base_path = path.sub(/\.rb$/, "")
172
+
173
+ # Check direct match
174
+ return true if STDLIB_MODULES.include?(base_path)
175
+
176
+ # Check prefix (e.g., 'net/http' for 'net/http/response')
177
+ STDLIB_MODULES.any? do |mod|
178
+ base_path.start_with?("#{mod}/") || base_path == mod
179
+ end
180
+ end
181
+
182
+ def stdlib_constant?(name)
183
+ # Common Ruby stdlib constants
184
+ stdlib_constants = %w[
185
+ Comparable
186
+ Enumerable
187
+ Forwardable
188
+ Observable
189
+ Singleton
190
+ MonitorMixin
191
+ Mutex_m
192
+ ]
193
+ stdlib_constants.any? { |c| name.start_with?(c) }
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Isort
4
+ # Validates Ruby syntax using the built-in parser
5
+ # Used by --atomic mode to ensure sorting doesn't introduce syntax errors
6
+ class SyntaxValidator
7
+ class << self
8
+ # Check if Ruby code has valid syntax
9
+ # Returns true if valid, false otherwise
10
+ def valid?(code)
11
+ check_syntax(code).nil?
12
+ end
13
+
14
+ # Check syntax and return error message if invalid, nil if valid
15
+ def check_syntax(code)
16
+ # Use Ruby's built-in syntax check
17
+ catch(:valid) do
18
+ eval("BEGIN { throw :valid }; #{code}", nil, "(syntax_check)", 0)
19
+ end
20
+ nil
21
+ rescue SyntaxError => e
22
+ e.message
23
+ rescue StandardError
24
+ # Other errors during eval don't indicate syntax errors
25
+ nil
26
+ end
27
+
28
+ # Check if a file has valid Ruby syntax
29
+ def valid_file?(file_path)
30
+ return false unless File.exist?(file_path)
31
+
32
+ content = File.read(file_path, encoding: "UTF-8")
33
+ valid?(content)
34
+ rescue Errno::ENOENT, Encoding::InvalidByteSequenceError
35
+ false
36
+ end
37
+
38
+ # Use Ruby's built-in -c flag for more accurate syntax checking
39
+ # This is safer than eval-based checking
40
+ def valid_with_ruby_c?(code)
41
+ require "open3"
42
+ require "tempfile"
43
+
44
+ Tempfile.create(["syntax_check", ".rb"]) do |f|
45
+ f.write(code)
46
+ f.flush
47
+
48
+ stdout, stderr, status = Open3.capture3("ruby", "-c", f.path)
49
+ status.success?
50
+ end
51
+ rescue StandardError
52
+ # If we can't run ruby -c, fall back to eval-based check
53
+ valid?(code)
54
+ end
55
+
56
+ # Check syntax using ruby -c (more reliable but slower)
57
+ # Returns nil if valid, error message if invalid
58
+ def check_syntax_with_ruby_c(code)
59
+ require "open3"
60
+ require "tempfile"
61
+
62
+ Tempfile.create(["syntax_check", ".rb"]) do |f|
63
+ f.write(code)
64
+ f.flush
65
+
66
+ stdout, stderr, status = Open3.capture3("ruby", "-c", f.path)
67
+ status.success? ? nil : stderr.strip
68
+ end
69
+ rescue StandardError => e
70
+ # If we can't run ruby -c, fall back to eval-based check
71
+ check_syntax(code)
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/isort/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Isort
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end