Skip to content

Commit

Permalink
Extract Sortable module, add StringSorter
Browse files Browse the repository at this point in the history
  • Loading branch information
borama committed Aug 8, 2024
1 parent d6274b7 commit 9d51da8
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 85 deletions.
30 changes: 29 additions & 1 deletion lib/tailwind_sorter/config.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
# frozen_string_literal: true

module TailwindSorter
module Config
class Config
DEFAULT_CONFIG_FILE = "config/tailwind_sorter.yml"

def initialize(config_file = DEFAULT_CONFIG_FILE)
unless config_file && File.exist?(config_file)
raise ArgumentError, "Configuration file '#{config_file}' does not exist"
end

@config_file = config_file
end

def load
@config = YAML.load_file(@config_file)
convert_class_order_regexps!
@config
end

private

def convert_class_order_regexps!
@config["classes_order"].each_value do |class_patterns|
class_patterns.map! do |class_or_pattern|
if (patterns = class_or_pattern.match(%r{\A/(.*)/\z}).to_a).empty?
class_or_pattern
else
/\A#{patterns.last}\z/
end
end
end
end
end
end
5 changes: 5 additions & 0 deletions lib/tailwind_sorter/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
require "tailwind_sorter/file_sorter"

module TailwindSorter
module DSL
def sort(classes_string, config_file: Config::DEFAULT_CONFIG_FILE)
TailwindSorter::StringSorter.new(config_file:).sort(classes_string)
end

def sort_file(file, warn_only: false, config_file: Config::DEFAULT_CONFIG_FILE)
TailwindSorter::FileSorter.new(warn_only:, config_file:).sort(file)
end
Expand Down
102 changes: 19 additions & 83 deletions lib/tailwind_sorter/file_sorter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

require "tempfile"
require "yaml"

require "tailwind_sorter/config"
require "tailwind_sorter/sortable"

module TailwindSorter
# Sorts Tailwind CSS classes in a file on lines matched by the configured "regexp" patterns.
class FileSorter
def initialize(warn_only: false, config_file: Config::DEFAULT_CONFIG_FILE)
unless config_file && File.exist?(config_file)
raise ArgumentError, "Configuration file '#{config_file}' does not exist"
end
include Sortable

def initialize(warn_only: false, config_file: Config::DEFAULT_CONFIG_FILE)
@warn_only = warn_only
@config = YAML.load_file(config_file)
@config = Config.new(config_file).load

@sorting_keys_cache = {}
end

Expand All @@ -25,51 +25,31 @@ def sort(file_name)

private

def sort_classes(file, default_index: 0)
file_extension = File.extname(file)
line_regexps = @config["regexps"].select { |_k, v| v["file_extension"] == file_extension }
line_regexps.each_value { |v| v["regexp"] = Regexp.new(v["regexp"]) }

classes_order = @config["classes_order"]
variants_order = @config["variants_order"]

convert_class_order_regexps!(classes_order)
class_groups = classes_order.keys
def sort_classes(file)
warnings = []

infile = File.open(file)
outfile = Tempfile.create("#{File.basename(file)}.sorted")

calculate_sorting_key = lambda do |css_class_with_variants|
sorting_key(css_class_with_variants, variants_order:, classes_order:, class_groups:, default_index:)
end

changed = false

infile.each_line do |line|
line_number = infile.lineno

line_regexps.each do |_regexp_name, regexp_config|
line_regexps_for(file).each_value do |regexp_config|
regexp = regexp_config["regexp"]
prefix = regexp_config["class_prefix"]
split_by = "#{regexp_config['class_splitter']}#{prefix}"

next unless (match = line.match(regexp))

matched_classes = match["classes"]
# puts "#{line_number}: #{matched_classes}"

classes = matched_classes.split(split_by).map(&:strip).reject(&:empty?).uniq.map do |css_class_with_variants|
sort_variants(css_class_with_variants, variants_order:)
end

sorted_classes = classes.sort_by { |css_class| calculate_sorting_key.call(css_class) }
# puts sorted_classes.join('.')
classes = match["classes"]
classes = classes.split(split_by).map(&:strip).reject(&:empty?).uniq
sorted_classes = sort_classes_array(classes)

if @warn_only
orig_keys = classes.map { |css_class| calculate_sorting_key.call(css_class) }
sorted_keys = sorted_classes.map { |css_class| calculate_sorting_key.call(css_class) }
# puts orig_keys.inspect
# puts sorted_keys.inspect
orig_keys = classes.map { |css_class| sorting_key_lambda.call(css_class) }
sorted_keys = sorted_classes.map { |css_class| sorting_key_lambda.call(css_class) }

if orig_keys != sorted_keys
warning = "#{file}:#{line_number}:CSS classes are not sorted well. Please run 'tailwind_sorter #{file}'."
warnings << warning
Expand Down Expand Up @@ -98,54 +78,10 @@ def sort_classes(file, default_index: 0)
success && changed ? FileUtils.mv(outfile, file) : File.unlink(outfile)
end

# reorders multiple variants, e.g.: "focus:sm:block" -> "sm:focus:block"
def sort_variants(css_class_with_variants, variants_order:)
*variants, css_class = css_class_with_variants.split(":")
return css_class_with_variants if variants.length <= 1

variants.sort_by! { |variant| variants_order.index(variant) }
"#{variants.join(':')}:#{css_class}"
end

# Constructs the sorting key for sorting CSS classes in the following way:
#
# group_index, variant1_index, variant2_index, class_index
# "sm:focus:flex" -> "01,01,11,0010"
# "flex" -> "01,00,00,0010"
# "custom-class" -> "00,00,00,0000"
def sorting_key(css_class_with_variants, variants_order:, classes_order:, class_groups:, default_index: 0)
return @sorting_keys_cache[css_class_with_variants] if @sorting_keys_cache[css_class_with_variants]

*variants, css_class = css_class_with_variants.split(":")

matching_index_in_group = nil
matching_group = class_groups.find do |group|
matching_index_in_group ||= classes_order[group].index { _1 === css_class }
end

key = [
format("%02d", (matching_group && class_groups.index(matching_group)) || default_index),
format("%02d", variants_order.index(variants[0]) || default_index),
format("%02d", variants_order.index(variants[1]) || default_index),
format("%04d", matching_index_in_group || default_index)
].join(",")

# puts "#{css_class_with_variants} #{key}"
@sorting_keys_cache[css_class_with_variants] = key
end

def convert_class_order_regexps!(classes_order)
classes_order.each do |group, class_patterns|
class_patterns.map! do |class_or_pattern|
if (patterns = class_or_pattern.match(%r{\A/(.*)/\z}).to_a).empty?
class_or_pattern
else
/\A#{patterns.last}\z/
end
end
end

classes_order
def line_regexps_for(file)
line_regexps = @config["regexps"].select { |_k, v| v["file_extension"] == File.extname(file) }
line_regexps.each_value { |v| v["regexp"] = Regexp.new(v["regexp"]) }
line_regexps
end
end
end
59 changes: 59 additions & 0 deletions lib/tailwind_sorter/sortable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module TailwindSorter
module Sortable
private

# Returns the Tailwind classes array sorted according to the config file.
def sort_classes_array(classes)
classes = classes.map do |css_class_with_variants|
sort_variants(css_class_with_variants)
end

classes.sort_by { |css_class| sorting_key_lambda.call(css_class) }
end

# Lambda for sorting the Tailwind CSS classes. It is memoized to avoid repeated class groups lookups.
def sorting_key_lambda(default_index: 0)
@sorting_key_lambda ||= begin
class_groups = @config["classes_order"].keys
->(tw_class) { sorting_key(tw_class, class_groups:, default_index:) }
end
end

# Reorders multiple variants, e.g.: "focus:sm:block" -> "sm:focus:block".
def sort_variants(css_class_with_variants)
*variants, css_class = css_class_with_variants.split(":")
return css_class_with_variants if variants.length <= 1

variants.sort_by! { |variant| @config["variants_order"].index(variant) }
"#{variants.join(':')}:#{css_class}"
end

# Constructs the sorting key for sorting CSS classes in the following way:
#
# group_index, variant1_index, variant2_index, class_index
# "sm:focus:flex" -> "01,01,11,0010"
# "flex" -> "01,00,00,0010"
# "custom-class" -> "00,00,00,0000"
def sorting_key(css_class_with_variants, class_groups:, default_index: 0)
return @sorting_keys_cache[css_class_with_variants] if @sorting_keys_cache[css_class_with_variants]

*variants, css_class = css_class_with_variants.split(":")

matching_index_in_group = nil
matching_group = class_groups.find do |group|
matching_index_in_group ||= @config["classes_order"][group].index { _1 === css_class }
end

key = [
format("%02d", (matching_group && class_groups.index(matching_group)) || default_index),
format("%02d", @config["variants_order"].index(variants[0]) || default_index),
format("%02d", @config["variants_order"].index(variants[1]) || default_index),
format("%04d", matching_index_in_group || default_index)
].join(",")

@sorting_keys_cache[css_class_with_variants] = key
end
end
end
21 changes: 21 additions & 0 deletions lib/tailwind_sorter/string_sorter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require "yaml"

require "tailwind_sorter/config"
require "tailwind_sorter/sortable"

module TailwindSorter
class StringSorter
include Sortable

def initialize(config_file: Config::DEFAULT_CONFIG_FILE)
@config = Config.new(config_file).load
@sorting_keys_cache = {}
end

def sort(classes_string)
sort_classes_array(classes_string.split.map(&:strip).reject(&:empty?).uniq).join(" ")
end
end
end
2 changes: 1 addition & 1 deletion spec/lib/tailwind_sorter/file_sorter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def file_content(file: test_file)
end

context "with regexp 'slim_html'" do
describe "class reordering with pure strings" do
describe "class reordering with pure strings in the config" do
it "does basic class reordering" do
expect(run_tailwind_sorter(".rounded.my-4.block")).to eq(".block.my-4.rounded")
end
Expand Down
57 changes: 57 additions & 0 deletions spec/lib/tailwind_sorter/string_sorter_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "spec_helper"
require "fileutils"
require "bundler"

require "tailwind_sorter"

RSpec.describe TailwindSorter::StringSorter do
let(:config_file) { "spec/support/basic_config.yml" }

def run_tailwind_sorter(content)
TailwindSorter::StringSorter.new(config_file:).sort(content)
end

describe "class reordering with pure strings in the config" do
it "does basic class reordering" do
expect(run_tailwind_sorter("rounded my-4 block")).to eq("block my-4 rounded")
end

it "orders unknown classes first" do
expect(run_tailwind_sorter("rounded my-4 block nr-class")).to eq("nr-class block my-4 rounded")
end

it "orders variants to the end of the same group" do
expect(run_tailwind_sorter("sm:block block lg:my-4 my-8")).to eq("block sm:block my-8 lg:my-4")
end

it "orders multiple variants" do
expect(run_tailwind_sorter("focus:hover:sm:block my-4")).to eq("sm:hover:focus:block my-4")
end
end

describe "class reordering with regexps in classes_order config" do
let(:config_file) { "spec/support/regexp_config.yml" }

it "does basic class reordering" do
expect(run_tailwind_sorter("rounded my-4 block")).to eq("block my-4 rounded")
end

it "orders unknown classes first" do
expect(run_tailwind_sorter("rounded my-4 block nr-class")).to eq("nr-class block my-4 rounded")
end

it "orders variants to the end of the same group" do
expect(run_tailwind_sorter("sm:block block lg:my-4 my-8")).to eq("block sm:block my-8 lg:my-4")
end

it "orders multiple variants" do
expect(run_tailwind_sorter("focus:hover:sm:block my-4")).to eq("sm:hover:focus:block my-4")
end
end

it "removes duplicate classes" do
expect(run_tailwind_sorter("block my-4 block")).to eq("block my-4")
end
end
24 changes: 24 additions & 0 deletions spec/lib/tailwind_sorter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
require "tailwind_sorter"

RSpec.describe TailwindSorter do
describe ".sort" do
it "calls StringSorter with default config" do
string_sorter_double = instance_double(TailwindSorter::StringSorter, sort: "sorted classes")
allow(TailwindSorter::StringSorter).to receive(:new).with(config_file: TailwindSorter::Config::DEFAULT_CONFIG_FILE)
.and_return(string_sorter_double)

TailwindSorter.sort("unsorted classes")

expect(TailwindSorter::StringSorter).to have_received(:new).once
expect(string_sorter_double).to have_received(:sort).with("unsorted classes")
end

it "calls StringSorter with custom config" do
string_sorter_double = instance_double(TailwindSorter::StringSorter, sort: "sorted classes")
allow(TailwindSorter::StringSorter).to receive(:new).with(config_file: "custom_config.yml")
.and_return(string_sorter_double)

TailwindSorter.sort("unsorted classes", config_file: "custom_config.yml")

expect(TailwindSorter::StringSorter).to have_received(:new).once
expect(string_sorter_double).to have_received(:sort).with("unsorted classes")
end
end

describe ".sort_file" do
it "calls FileSorter with default config" do
file_sorter_double = instance_double(TailwindSorter::FileSorter, sort: "sorted.classes")
Expand Down

0 comments on commit 9d51da8

Please sign in to comment.