Module: Kettle::Jem::ReadmePostProcessor

Defined in:
lib/kettle/jem.rb

Constant Summary collapse

ENGINE_COMPATIBILITY_MRI_VERSION =
{
  "jruby" => {
    "9.1" => Gem::Version.new("2.3"),
    "9.2" => Gem::Version.new("2.5"),
    "9.3" => Gem::Version.new("2.6"),
    "9.4" => Gem::Version.new("3.1"),
    "10.0" => Gem::Version.new("3.4")
  }.freeze,
  "truby" => {
    "22.3" => Gem::Version.new("3.0"),
    "23.0" => Gem::Version.new("3.0"),
    "23.1" => Gem::Version.new("3.1"),
    "24.2" => Gem::Version.new("3.3"),
    "25.0" => Gem::Version.new("3.3"),
    "33.0" => Gem::Version.new("3.3")
  }.freeze
}.freeze
COMPATIBILITY_REFERENCE_LABEL_RE =
/\A(?:💎(?:ruby|jruby|truby)-|🚎)/
ENGINE_ROW_PATTERNS =
Ractor.make_shareable({
  "jruby" => {
    row_prefix: "| Works with JRuby",
    badge_prefixes: %w[💎jruby-],
    ref_prefixes: [/\A🚎jruby-/, /\A🚎\d+-j-/]
  }.freeze,
  "truffleruby" => {
    row_prefix: "| Works with Truffle Ruby",
    badge_prefixes: %w[💎truby-],
    ref_prefixes: [/\A🚎truby-/, /\A🚎\d+-t-/]
  }.freeze
})
VERSIONED_ENGINE_COMPATIBILITY_BADGES =
[
  {
    engine: "jruby",
    version: "10.0",
    workflow_path: ".github/workflows/jruby-10.0.yml",
    workflow_file: "jruby-10.0.yml",
    row_prefix: "| Works with JRuby",
    badge_label: "💎jruby-10.0i",
    workflow_label: "🚎jruby-10.0-wf",
    inline: "[![JRuby 10.0 Compat][💎jruby-10.0i]][🚎jruby-10.0-wf]",
    insert_before: "💎jruby-c-i",
    badge_definition: "[💎jruby-10.0i]: https://img.shields.io/badge/JRuby-10.0-FBE742?style=for-the-badge&logo=ruby&logoColor=red"
  }.freeze,
  {
    engine: "truby",
    version: "33.0",
    workflow_path: ".github/workflows/truffleruby-33.0.yml",
    workflow_file: "truffleruby-33.0.yml",
    row_prefix: "| Works with Truffle Ruby",
    badge_label: "💎truby-33.0i",
    workflow_label: "🚎truby-33.0-wf",
    inline: "[![Truffle Ruby 33.0 Compat][💎truby-33.0i]][🚎truby-33.0-wf]",
    insert_before: "💎truby-c-i",
    badge_definition: "[💎truby-33.0i]: https://img.shields.io/badge/Truffle_Ruby-33.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink"
  }.freeze
].freeze

Class Method Summary collapse

Class Method Details

.add_supported_versioned_engine_badges(content, min_ruby, workflow_paths) ⇒ Object



893
894
895
896
897
898
899
900
901
902
903
904
905
# File 'lib/kettle/jem.rb', line 893

def add_supported_versioned_engine_badges(content, min_ruby, workflow_paths)
  existing_workflows = Array(workflow_paths).map { |path| path.to_s.delete_prefix("./") }.to_set
  VERSIONED_ENGINE_COMPATIBILITY_BADGES.reduce(content.to_s) do |processed, badge|
    next processed unless existing_workflows.include?(badge.fetch(:workflow_path))
    next processed if processed.include?("[#{badge.fetch(:badge_label)}]:")
    next processed unless versioned_engine_badge_compatible?(badge, min_ruby)

    with_inline = add_versioned_engine_badge_to_row(processed, badge)
    next processed if with_inline == processed

    add_versioned_engine_badge_definitions(with_inline, badge)
  end
end

.add_versioned_engine_badge_definitions(content, badge) ⇒ Object



929
930
931
932
933
934
935
936
937
938
939
940
# File 'lib/kettle/jem.rb', line 929

def add_versioned_engine_badge_definitions(content, badge)
  processed = ensure_markdown_link_definition(content, badge.fetch(:badge_definition))
  return processed if processed.include?("[#{badge.fetch(:workflow_label)}]:")

  workflow_base = workflow_definition_base_url(processed)
  return processed unless workflow_base

  ensure_markdown_link_definition(
    processed,
    "[#{badge.fetch(:workflow_label)}]: #{workflow_base}/actions/workflows/#{badge.fetch(:workflow_file)}"
  )
end

.add_versioned_engine_badge_to_row(content, badge) ⇒ Object



912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
# File 'lib/kettle/jem.rb', line 912

def add_versioned_engine_badge_to_row(content, badge)
  lines = content.lines
  row_index = lines.index { |line| line.lstrip.start_with?(badge.fetch(:row_prefix)) }
  return content unless row_index
  return content if lines.fetch(row_index).include?(badge.fetch(:badge_label))

  line = lines.fetch(row_index)
  insert_before = badge.fetch(:insert_before)
  inline = badge.fetch(:inline)
  lines[row_index] = if line.include?("[#{insert_before}]")
    line.sub(/(?=\[!\[[^\]]+\]\[#{Regexp.escape(insert_before)}\])/, "#{inline} ")
  else
    line.sub(/\s*\|\s*$/, " #{inline}|")
  end
  lines.join
end

.compatibility_badge_min_mri(label) ⇒ Object



968
969
970
971
972
973
974
975
976
# File 'lib/kettle/jem.rb', line 968

def compatibility_badge_min_mri(label)
  if (match = label.match(/\A💎ruby-(?<version>\d+\.\d+)i\z/))
    Gem::Version.new(match[:version])
  elsif (match = label.match(/\A💎(?<engine>jruby|truby)-(?<version>\d+\.\d+)i\z/))
    ENGINE_COMPATIBILITY_MRI_VERSION.dig(match[:engine], match[:version])
  end
rescue
  nil
end

.compatibility_row?(line) ⇒ Boolean

Returns:

  • (Boolean)


1073
1074
1075
1076
# File 'lib/kettle/jem.rb', line 1073

def compatibility_row?(line)
  text = line.to_s.lstrip
  text.start_with?("| Works with MRI Ruby", "| Works with JRuby", "| Works with Truffle Ruby")
end


1078
1079
1080
1081
1082
1083
1084
1085
1086
# File 'lib/kettle/jem.rb', line 1078

def delete_markdown_link_definitions(content, labels)
  Kettle::Jem.ensure_runtime_dependencies!
  targets = labels.uniq.map do |label|
    Ast::Crispr::Markdown::Markly::Selectors.link_definition(label: label, limit: {at_least: 0})
  end
  return content if targets.empty?

  Ast::Crispr::DeleteBatch.call(content: content.to_s, targets: targets, source_label: "README.md").updated_content
end


942
943
944
945
946
947
948
# File 'lib/kettle/jem.rb', line 942

def ensure_markdown_link_definition(content, definition)
  label = definition[/\A(\[[^\]]+\]):/, 1]
  return content if label && content.include?("#{label}:")

  separator = content.end_with?("\n") ? "" : "\n"
  "#{content}#{separator}#{definition}\n"
end

.markdown_inline_reference_owners(content) ⇒ Object



1047
1048
1049
1050
1051
1052
1053
# File 'lib/kettle/jem.rb', line 1047

def markdown_inline_reference_owners(content)
  Kettle::Jem.ensure_runtime_dependencies!
  context = Ast::Crispr::Markdown::Markly.document_context(content: content.to_s, source_label: "README.md")
  context.structural_owners(owner_scope: :inline_references)
rescue Ast::Crispr::Error
  []
end


1039
1040
1041
1042
1043
1044
1045
# File 'lib/kettle/jem.rb', line 1039

def markdown_link_definition_owners(content)
  Kettle::Jem.ensure_runtime_dependencies!
  context = Ast::Crispr::Markdown::Markly.document_context(content: content.to_s, source_label: "README.md")
  context.structural_owners(owner_scope: :link_definitions)
rescue Ast::Crispr::Error
  []
end

.markdown_structural_owners(content, *owner_scopes) ⇒ Object



1063
1064
1065
1066
1067
1068
1069
1070
1071
# File 'lib/kettle/jem.rb', line 1063

def markdown_structural_owners(content, *owner_scopes)
  Kettle::Jem.ensure_runtime_dependencies!
  context = Ast::Crispr::Markdown::Markly.document_context(content: content.to_s, source_label: "README.md")
  owner_scopes.each_with_object({}) do |owner_scope, owners|
    owners[owner_scope] = context.structural_owners(owner_scope: owner_scope)
  end
rescue Ast::Crispr::Error
  owner_scopes.to_h { |owner_scope| [owner_scope, []] }
end

.markdown_table_row_owners(content) ⇒ Object



1055
1056
1057
1058
1059
1060
1061
# File 'lib/kettle/jem.rb', line 1055

def markdown_table_row_owners(content)
  Kettle::Jem.ensure_runtime_dependencies!
  context = Ast::Crispr::Markdown::Markly.document_context(content: content.to_s, source_label: "README.md")
  context.structural_owners(owner_scope: :table_rows)
rescue Ast::Crispr::Error
  []
end

.normalize_compatibility_badge_cell(cell) ⇒ Object



1006
1007
1008
1009
1010
1011
1012
1013
1014
# File 'lib/kettle/jem.rb', line 1006

def normalize_compatibility_badge_cell(cell)
  cell.to_s
    .split(/<br\/>/i)
    .filter_map do |segment|
      normalized = segment.gsub(/[ \t]+/, " ").strip
      normalized unless normalized.empty?
    end
    .join(" <br/> ")
end

.normalize_compatibility_rows(content) ⇒ Object



987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
# File 'lib/kettle/jem.rb', line 987

def normalize_compatibility_rows(content)
  lines = content.lines
  markdown_table_row_owners(content).each do |owner|
    line = lines[owner.location.start_line - 1]
    next unless compatibility_row?(line)

    cells = line.split("|", -1)
    badge_cell = normalize_compatibility_badge_cell(cells[2])
    if badge_cell.empty?
      lines[owner.location.start_line - 1] = nil
      next
    end

    cells[2] = " #{badge_cell}"
    lines[owner.location.start_line - 1] = cells.join("|")
  end
  lines.compact.join
end

.process(content:, min_ruby:, engines: nil, workflow_paths: nil) ⇒ Object



823
824
825
826
827
828
829
830
831
832
833
834
835
# File 'lib/kettle/jem.rb', line 823

def process(content:, min_ruby:, engines: nil, workflow_paths: nil)
  Kettle::Jem.ensure_runtime_dependencies!
  processed = content.to_s
  processed = remove_disabled_engine_content(processed, engines) if engines
  processed = remove_missing_workflow_badges(processed, workflow_paths) if workflow_paths
  return processed if min_ruby.to_s.empty?

  min_ruby_version = Gem::Version.new(min_ruby.to_s)
  processed = add_supported_versioned_engine_badges(processed, min_ruby_version, workflow_paths) if workflow_paths
  processed = remove_incompatible_compatibility_badges(processed, min_ruby_version)
  processed = normalize_compatibility_rows(processed)
  prune_unused_compatibility_reference_definitions(processed)
end

.prune_orphaned_workflow_inline_references(content) ⇒ Object



1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
# File 'lib/kettle/jem.rb', line 1027

def prune_orphaned_workflow_inline_references(content)
  owners = markdown_structural_owners(content, :link_definitions, :inline_references)
  defined_labels = owners.fetch(:link_definitions).map { |owner| owner.label.to_s }.to_set
  orphaned_labels = owners.fetch(:inline_references).flat_map(&:labels).map(&:to_s).uniq.select do |label|
    label.start_with?("🚎") && !defined_labels.include?(label)
  end
  processed = orphaned_labels.reduce(content.to_s) do |memo, label|
    remove_markdown_inline_references(memo, label)
  end
  normalize_compatibility_rows(processed)
end

.prune_unused_compatibility_reference_definitions(content) ⇒ Object



1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
# File 'lib/kettle/jem.rb', line 1016

def prune_unused_compatibility_reference_definitions(content)
  owners = markdown_structural_owners(content, :inline_references, :link_definitions)
  referenced_labels = owners.fetch(:inline_references).flat_map(&:labels).to_h { |label| [label, true] }

  labels = owners.fetch(:link_definitions).filter_map do |owner|
    label = owner.label.to_s
    label if COMPATIBILITY_REFERENCE_LABEL_RE.match?(label) && !referenced_labels[label]
  end
  delete_markdown_link_definitions(content, labels)
end

.remove_badge_occurrences(content, label) ⇒ Object



983
984
985
# File 'lib/kettle/jem.rb', line 983

def remove_badge_occurrences(content, label)
  remove_markdown_inline_references(content, label)
end

.remove_disabled_engine_content(content, engines) ⇒ Object



837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
# File 'lib/kettle/jem.rb', line 837

def remove_disabled_engine_content(content, engines)
  enabled = Array(engines).map { |engine| engine.to_s.strip.downcase }
  processed = content.to_s

  ENGINE_ROW_PATTERNS.each do |engine, patterns|
    next if enabled.include?(engine)

    processed = remove_markdown_table_rows(processed) do |owner|
      owner.source.to_s.lstrip.start_with?(patterns.fetch(:row_prefix))
    end
    labels = markdown_inline_reference_owners(processed).flat_map(&:labels).uniq
    labels.each do |label|
      next unless patterns.fetch(:badge_prefixes).any? { |prefix| label.start_with?(prefix) }

      processed = remove_badge_occurrences(processed, label)
    end
    ref_labels = markdown_link_definition_owners(processed).filter_map do |owner|
      label = owner.label.to_s
      label if patterns.fetch(:ref_prefixes).any? { |pattern| pattern.match?(label) }
    end
    ref_labels.each do |label|
      processed = remove_markdown_inline_references(processed, label)
    end
    processed = delete_markdown_link_definitions(processed, ref_labels)
  end

  processed
end

.remove_incompatible_compatibility_badges(content, min_ruby) ⇒ Object



957
958
959
960
961
962
963
964
965
966
# File 'lib/kettle/jem.rb', line 957

def remove_incompatible_compatibility_badges(content, min_ruby)
  markdown_inline_reference_owners(content).flat_map(&:labels).uniq.each do |label|
    badge_min_mri = compatibility_badge_min_mri(label)
    next unless badge_min_mri && ruby_minor_version(badge_min_mri) < ruby_minor_version(min_ruby)

    content = remove_badge_occurrences(content, label)
  end

  content
end

.remove_markdown_inline_references(content, label) ⇒ Object



1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
# File 'lib/kettle/jem.rb', line 1088

def remove_markdown_inline_references(content, label)
  lines = content.to_s.lines
  markdown_inline_reference_owners(content).select { |owner| owner.labels.include?(label.to_s) }
    .group_by(&:line)
    .each do |line_number, owners|
      index = line_number - 1
      line = lines[index].to_s
      owners.sort_by(&:start_column).reverse_each do |owner|
        start_column = owner.start_column
        end_column = owner.end_column
        if start_column.positive? && line[start_column - 1] == " "
          start_column -= 1
        elsif line[end_column] == " "
          end_column += 1
        end
        line = "#{line[0...start_column]}#{line[end_column..]}"
      end
      lines[index] = line
    end
  lines.join
end

.remove_markdown_table_rows(content) ⇒ Object



1110
1111
1112
1113
1114
1115
1116
# File 'lib/kettle/jem.rb', line 1110

def remove_markdown_table_rows(content)
  lines = content.to_s.lines
  markdown_table_row_owners(content).select { |owner| yield owner }.reverse_each do |owner|
    lines.delete_at(owner.location.start_line - 1)
  end
  lines.join
end

.remove_missing_workflow_badges(content, workflow_paths) ⇒ Object



866
867
868
869
870
871
872
873
874
875
# File 'lib/kettle/jem.rb', line 866

def remove_missing_workflow_badges(content, workflow_paths)
  existing = Array(workflow_paths).map { |path| path.to_s.delete_prefix("./") }.to_set
  workflow_references(content).each do |label, workflow_path|
    next if existing.include?(workflow_path)

    content = remove_workflow_badge_occurrences(content, label)
  end

  content
end

.remove_workflow_badge_occurrences(content, workflow_label) ⇒ Object



889
890
891
# File 'lib/kettle/jem.rb', line 889

def remove_workflow_badge_occurrences(content, workflow_label)
  remove_markdown_inline_references(content, workflow_label)
end

.ruby_minor_version(version) ⇒ Object



978
979
980
981
# File 'lib/kettle/jem.rb', line 978

def ruby_minor_version(version)
  segments = version.segments
  Gem::Version.new("#{segments[0]}.#{segments[1] || 0}")
end

.versioned_engine_badge_compatible?(badge, min_ruby) ⇒ Boolean

Returns:

  • (Boolean)


907
908
909
910
# File 'lib/kettle/jem.rb', line 907

def versioned_engine_badge_compatible?(badge, min_ruby)
  badge_min_mri = ENGINE_COMPATIBILITY_MRI_VERSION.dig(badge.fetch(:engine), badge.fetch(:version))
  badge_min_mri && ruby_minor_version(badge_min_mri) >= ruby_minor_version(min_ruby)
end

.workflow_definition_base_url(content) ⇒ Object



950
951
952
953
954
955
# File 'lib/kettle/jem.rb', line 950

def workflow_definition_base_url(content)
  markdown_link_definition_owners(content).filter_map do |owner|
    url = owner.url.to_s
    url.split("/actions/workflows/", 2).first if url.include?("/actions/workflows/")
  end.first
end

.workflow_references(content) ⇒ Object



877
878
879
880
881
882
883
884
885
886
887
# File 'lib/kettle/jem.rb', line 877

def workflow_references(content)
  markdown_structural_owners(content, :link_definitions).fetch(:link_definitions).each_with_object({}) do |owner, references|
    workflow = URI(owner.url.to_s).path.to_s.split("/actions/workflows/", 2).last
    next unless workflow
    workflow = workflow.split("/", 2).first

    references[owner.label.to_s] = ".github/workflows/#{workflow}"
  end
rescue URI::InvalidURIError
  {}
end