css_class_duplicates 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed329f3f9209fa5ff1ff79b79714d651327162fbf6c99c5f1c9619f843c60c6c
4
+ data.tar.gz: aceea4196b63d6bd9343baf34a193c5b65d64f39f7423b8f549c38296cf4d889
5
+ SHA512:
6
+ metadata.gz: 2588d4c48f3ff26c61b227265fdf3365a6eb4adc272b9839b070a7f7ca51bd96c686489124cff2b32781b77d0e1d9d674d6d2f9a2d3ebcb7b68e7e22cd5a2e43
7
+ data.tar.gz: 4b24ca2142a1ca85c0f6dbb44970619efebc25b3f0f119906a1f24cb3353d15d7558625fac1f90f846a463586961c7a745f4573dc6790a40cf8b1df892cf65af
@@ -0,0 +1,22 @@
1
+ files:
2
+ include:
3
+ - "**/*.html.erb"
4
+ # exclude:
5
+ # - "app/views/layouts/**"
6
+
7
+ matches:
8
+ type: 'exact'
9
+ # type: 'partial'
10
+
11
+ filters:
12
+ classes:
13
+ min: 2
14
+ # include:
15
+ # - '*' # Include the match only if it contains the class
16
+ # exclude:
17
+ # - 'first:*' # Exclude the match if it contains the class
18
+ # - 'btn btn-primary' # Exclude the match if it contains the classes
19
+ # delete:
20
+ # - 'hidden' # Deletes the class from match
21
+ matches:
22
+ min: 2
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift("#{__dir__}/../lib")
5
+
6
+ require 'benchmark'
7
+ require 'css_class_duplicates'
8
+
9
+ cli = CssClassDuplicates::CLI.new
10
+ result = 0
11
+
12
+ time = Benchmark.realtime { result = cli.run }
13
+
14
+ puts "Finished in #{time} seconds"
15
+ exit result
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # The CLI is a class responsible of handling all the command line interface logic.
5
+ class CLI
6
+ STATUS_SUCCESS = 0
7
+ STATUS_ERROR = 0
8
+
9
+ def initialize
10
+ @options = {}
11
+ end
12
+
13
+ def run
14
+ entities = []
15
+
16
+ config = Config.new
17
+ config.files.each do |file_name|
18
+ entities.concat(config.parser.call(file_name))
19
+ end
20
+
21
+ entities = config.merger.call(entities)
22
+ entities = config.filter.call(entities)
23
+ config.printer.call(entities)
24
+
25
+ STATUS_SUCCESS
26
+ rescue StandardError, SyntaxError, LoadError => e
27
+ warn e.message
28
+ warn e.backtrace
29
+ STATUS_ERROR
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'rake/file_list'
5
+
6
+ module CssClassDuplicates
7
+ # This class represents the configuration of the gem.
8
+ # A Config is associated with a YAML configuration
9
+ # file from which it was read.
10
+ class Config
11
+ PROJECT_CONFIG = '.css_class_duplicates.yml'
12
+ DEFAULT_CONFIG = File.expand_path('../../config/default.yml', __dir__)
13
+
14
+ def initialize
15
+ filename = File.exist?(PROJECT_CONFIG) ? PROJECT_CONFIG : DEFAULT_CONFIG
16
+ @config = YAML.load_file(filename)
17
+ end
18
+
19
+ def files
20
+ Rake::FileList.new.tap do |file_list|
21
+ if (files = @config['files'])
22
+ if (include = files['include'])
23
+ file_list.include(include)
24
+ end
25
+ if (exclude = files['exclude'])
26
+ file_list.exclude(exclude)
27
+ end
28
+ else
29
+ file_list.include('**/*')
30
+ end
31
+ end
32
+ end
33
+
34
+ def parser
35
+ @parser ||= Parser.new(@config['filters'])
36
+ end
37
+
38
+ def merger
39
+ @merger ||= Merger.new(@config['matches'])
40
+ end
41
+
42
+ def filter
43
+ @filter ||= Filter.new(@config['filters'])
44
+ end
45
+
46
+ def printer
47
+ @printer ||= Printer.new
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # This class filters found results to exclude CSS classes that aren't duplicated
5
+ class Filter
6
+ def initialize(config)
7
+ @min_classes = config.dig('classes', 'min') || 2
8
+ @min_matches = config.dig('matches', 'min') || 2
9
+ end
10
+
11
+ def call(entities)
12
+ entities.select do |entity|
13
+ entity.classes.size >= @min_classes && entity.files.size >= @min_matches
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # The class compares strings using wildcard symbols
5
+ class Globber
6
+ def self.for(value)
7
+ return value if value.is_a?(Regexp)
8
+
9
+ escaped = Regexp.escape(value).gsub('\*', '.*?')
10
+ Regexp.new("^#{escaped}$", Regexp::IGNORECASE)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ Match = Struct.new(:classes, :files)
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # This class merges found results
5
+ class Merger
6
+ def initialize(config)
7
+ @type = config['type']
8
+ end
9
+
10
+ def call(entities)
11
+ matches = entities.group_by(&:classes).map do |classes, items|
12
+ Match.new(classes, items.map(&:files).flatten)
13
+ end
14
+ matches = add_partial_matches(matches) if @type == 'partial'
15
+ matches
16
+ end
17
+
18
+ private
19
+
20
+ def add_partial_matches(matches)
21
+ match_idx = matches.to_h { |match| [match.classes, match] }
22
+
23
+ matches.combination(2) do |match1, match2|
24
+ partial = match1.classes.intersection(match2.classes)
25
+ next if partial.empty?
26
+
27
+ existing = (match_idx[partial] ||= Match.new(partial, []))
28
+ existing.files = (existing.files + match1.files + match2.files).uniq
29
+ end
30
+
31
+ match_idx.values
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # This class searches for CSS classes
5
+ class Parser
6
+ MATCHER = /
7
+ \s+class\s*=\s*["']?([\w\s\-_:]+)["']?| # class = "hello world"
8
+ class:\s*["']?([\w\s\-_:]+)["']?| # class: 'hello world'
9
+ ["']?class["']?\s=>\s*["']?([\w\s\-_:]+)["']? # 'class' => "hello world"
10
+ /x.freeze
11
+ WHITESPACE = /\s/.freeze
12
+
13
+ def initialize(config)
14
+ classes = config.dig('classes')
15
+ @include = to_regex_array(classes&.dig('include'))
16
+ @exclude = to_regex_array(classes&.dig('exclude'))
17
+ @delete = to_regex_array(classes&.dig('delete'))
18
+ end
19
+
20
+ def call(file_name)
21
+ content = File.read(file_name)
22
+
23
+ matches = Scanner.new(content).matches(MATCHER)
24
+ matches.map do |match, line|
25
+ classes = match.split(WHITESPACE).reject(&:empty?)
26
+ next if classes.empty?
27
+
28
+ classes = filter(classes.map(&:downcase).sort)
29
+ next if classes.empty?
30
+
31
+ Match.new(classes, ["#{file_name}:#{line}"])
32
+ end.compact
33
+ end
34
+
35
+ private
36
+
37
+ def filter(classes)
38
+ return [] if @include.any? && !any?(@include, classes)
39
+ return [] if @exclude.any? && any?(@exclude, classes)
40
+
41
+ classes.reject { |value| include?(@delete, value) }
42
+ end
43
+
44
+ def any?(list, values)
45
+ values.any? { |value| include?(list, value) } || include?(list, values.join(' '))
46
+ end
47
+
48
+ def include?(list, value)
49
+ list.any? { |regex| value =~ regex }
50
+ end
51
+
52
+ def to_regex_array(array)
53
+ array&.map { |pattern| Globber.for(pattern) } || []
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ # This class renders the result in console.
5
+ class Printer
6
+ def call(entities)
7
+ entities.sort_by { |entity| [-entity.files.size, -entity.classes.size] }.each do |entity|
8
+ puts "#{entity.files.size} matches for \"#{entity.classes.join(' ')}\""
9
+ entity.files.uniq.sort.each do |file_name|
10
+ puts " #{file_name}"
11
+ end
12
+ puts
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ module CssClassDuplicates
6
+ # This class scans string and returns all found matches with positions
7
+ class Scanner
8
+ NEW_LINE = /(\r\n|\r|\n)/.freeze
9
+
10
+ def initialize(content)
11
+ @content = content
12
+ end
13
+
14
+ def matches(regex)
15
+ matches_with_positions(regex).map do |match, start_position|
16
+ [match, file_line(start_position)]
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def matches_with_positions(regex)
23
+ matches = []
24
+ @content.scan(regex) do |captures|
25
+ captures.each_with_index do |capture, index|
26
+ next unless capture
27
+
28
+ start_position, end_position = $LAST_MATCH_INFO.offset(index + 1)
29
+ matches << [capture, start_position, end_position]
30
+ end
31
+ end
32
+ matches
33
+ end
34
+
35
+ def file_line(position)
36
+ file_lines.index { |line_length| (position -= line_length) <= 0 } + 1
37
+ end
38
+
39
+ def file_lines
40
+ @file_lines ||= @content.split(NEW_LINE).map(&:length).each_slice(2).map(&:sum)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CssClassDuplicates
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'css_class_duplicates/cli'
4
+ require_relative 'css_class_duplicates/config'
5
+ require_relative 'css_class_duplicates/filter'
6
+ require_relative 'css_class_duplicates/globber'
7
+ require_relative 'css_class_duplicates/match'
8
+ require_relative 'css_class_duplicates/merger'
9
+ require_relative 'css_class_duplicates/parser'
10
+ require_relative 'css_class_duplicates/printer'
11
+ require_relative 'css_class_duplicates/scanner'
12
+ require_relative 'css_class_duplicates/version'
13
+
14
+ module CssClassDuplicates
15
+ class Error < StandardError; end
16
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: css_class_duplicates
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Kushnir
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-07-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |2
14
+ The gem searches for CSS class set duplicates, so they can be replaced with something meaningful,
15
+ like "border border-gray-400 bg-gray-100 mt-1 ..." can be replaced with "widget".
16
+ Supports Tailwind CSS and its alternatives
17
+ email:
18
+ - akushnir2009@gmail.com
19
+ executables:
20
+ - css_class_duplicates
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - config/default.yml
25
+ - exe/css_class_duplicates
26
+ - lib/css_class_duplicates.rb
27
+ - lib/css_class_duplicates/cli.rb
28
+ - lib/css_class_duplicates/config.rb
29
+ - lib/css_class_duplicates/filter.rb
30
+ - lib/css_class_duplicates/globber.rb
31
+ - lib/css_class_duplicates/match.rb
32
+ - lib/css_class_duplicates/merger.rb
33
+ - lib/css_class_duplicates/parser.rb
34
+ - lib/css_class_duplicates/printer.rb
35
+ - lib/css_class_duplicates/scanner.rb
36
+ - lib/css_class_duplicates/version.rb
37
+ homepage: https://github.com/a-kushnir/css_class_duplicates
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ homepage_uri: https://github.com/a-kushnir/css_class_duplicates
42
+ source_code_uri: https://github.com/a-kushnir/css_class_duplicates
43
+ bug_tracker_uri: https://github.com/a-kushnir/css_class_duplicates/issues
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 2.5.0
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 3.3.10
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: CSS class duplication search tool.
63
+ test_files: []