jekyll_plugin_support 1.1.0 → 3.0.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/CHANGELOG.md +17 -6
  4. data/README.md +430 -2
  5. data/jekyll_plugin_support.gemspec +2 -1
  6. data/lib/block/jekyll_plugin_support_block.rb +2 -4
  7. data/lib/block/jekyll_plugin_support_block_noarg.rb +0 -2
  8. data/lib/generator/jekyll_plugin_support_generator.rb +1 -7
  9. data/lib/helper/jekyll_plugin_helper.rb +5 -5
  10. data/lib/helper/jekyll_plugin_helper_class.rb +2 -2
  11. data/lib/hooks/a_page.rb +69 -0
  12. data/lib/hooks/all_collections_hooks.rb +61 -0
  13. data/lib/hooks/all_files.rb +48 -0
  14. data/lib/hooks/class_methods.rb +50 -0
  15. data/lib/jekyll_all_collections/all_collections_tag.rb +157 -0
  16. data/lib/jekyll_plugin_support/jekyll_plugin_support_class.rb +3 -5
  17. data/lib/jekyll_plugin_support/jekyll_plugin_support_spec_support.rb +1 -3
  18. data/lib/jekyll_plugin_support/version.rb +1 -1
  19. data/lib/jekyll_plugin_support.rb +17 -12
  20. data/lib/tag/jekyll_plugin_support_tag.rb +1 -4
  21. data/lib/tag/jekyll_plugin_support_tag_noarg.rb +0 -2
  22. data/lib/util/mslinn_binary_search.rb +152 -0
  23. data/lib/util/send_chain.rb +56 -0
  24. data/spec/all_collections_tag/all_collections_tag_sort_spec.rb +112 -0
  25. data/spec/bsearch_spec.rb +50 -0
  26. data/spec/custom_error_spec.rb +9 -9
  27. data/spec/date_sort_spec.rb +84 -0
  28. data/spec/jekyll_plugin_helper_options_spec.rb +7 -3
  29. data/spec/liquid_variable_parsing_spec.rb +6 -6
  30. data/spec/mslinn_binary_search_spec.rb +47 -0
  31. data/spec/send_chain_spec.rb +72 -0
  32. data/spec/send_spec.rb +28 -0
  33. data/spec/sorted_lru_files_spec.rb +82 -0
  34. data/spec/spec_helper.rb +2 -0
  35. data/spec/status_persistence.txt +3 -9
  36. data/spec/testable_spec.rb +38 -0
  37. metadata +45 -5
@@ -0,0 +1,69 @@
1
+ module AllCollectionsHooks
2
+ class APage
3
+ attr_reader :content, :data, :date, :description, :destination, :draft, :excerpt, :ext, :extname, :href,
4
+ :label, :last_modified, :layout, :origin, :path, :relative_path, :tags, :title, :type, :url
5
+
6
+ def initialize(obj, origin)
7
+ @origin = origin
8
+ data_field_init obj
9
+ obj_field_init obj
10
+ @draft = Jekyll::Draft.draft? obj
11
+ @href = @url if @href.nil?
12
+ # @href = "/#{@href}" if @origin == 'individual_page'
13
+ @href = "#{@href}index.html" if @href.end_with? '/'
14
+ @name = File.basename(@href)
15
+ @title = if @data&.key?('title')
16
+ @data['title']
17
+ elsif obj.respond_to?(:title)
18
+ obj.title
19
+ else
20
+ "<code>#{@href}</code>"
21
+ end
22
+ rescue StandardError => e
23
+ JekyllSupport.error_short_trace(@logger, e)
24
+ # JekyllSupport.warn_short_trace(@logger, e)
25
+ end
26
+
27
+ def to_s
28
+ @label || @date.to_s
29
+ end
30
+
31
+ private
32
+
33
+ def data_field_init(obj)
34
+ return unless obj.respond_to? :data
35
+
36
+ @data = obj.data
37
+
38
+ @categories = @data['categories'] if @data.key? 'categories'
39
+ @date = (@data['date'].to_date if @data&.key?('date')) || Date.today
40
+ @description = @data['description'] if @data.key? 'description'
41
+ @excerpt = @data['excerpt'] if @data.key? 'excerpt'
42
+ @ext ||= @data['ext'] if @data.key? 'ext'
43
+ @last_modified = @data['last_modified'] || @data['last_modified_at'] || @date
44
+ @last_modified_field = case @data
45
+ when @data.key?('last_modified')
46
+ 'last_modified'
47
+ when @data.key?('last_modified_at')
48
+ 'last_modified_at'
49
+ end
50
+ @layout = @data['layout'] if @data.key? 'layout'
51
+ @tags = @data['tags'] if @data.key? 'tags'
52
+ end
53
+
54
+ def obj_field_init(obj)
55
+ @content = obj.content if obj.respond_to? :content
56
+
57
+ # TODO: What _config.yml setting should be passed to destination()?
58
+ @destination = obj.destination('') if obj.respond_to? :destination
59
+ @ext = obj.extname
60
+ @extname = @ext # For compatibility with previous versions of all_collections
61
+ @label = obj.collection.label if obj.respond_to?(:collection) && obj.collection.respond_to?(:label)
62
+ @path = obj.path if obj.respond_to? :path
63
+ @relative_path = obj.relative_path if obj.respond_to? :relative_path
64
+ @type = obj.type if obj.respond_to? :type
65
+ @url = obj.url
66
+ @url = "#{@url}index.html" if @url.end_with? '/'
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ module JekyllAllCollections
2
+ module AllCollectionsHooks
3
+ class << self
4
+ attr_accessor :logger
5
+ end
6
+ @logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
7
+
8
+ # No, all_collections is not defined for this hook
9
+ # Jekyll::Hooks.register(:site, :after_init, priority: :normal) do |site|
10
+ # defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
11
+ # @logger.debug { "Jekyll::Hooks.register(:site, :after_init: #{defined_msg}" }
12
+ # end
13
+
14
+ # Creates a `Array[APage]` property called site.all_collections if it does not already exist.
15
+ # The array is available from :site, :pre_render onwards
16
+ # Each `APage` entry is one document or page.
17
+ Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
18
+ @site = site
19
+ unless site.class.method_defined? :all_collections
20
+ defined_msg = ::AllCollectionsHooks.all_collections_defined? site
21
+ @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
22
+ ::AllCollectionsHooks.compute(site) if !@site.class.method_defined?(:all_documents) || @site.all_documents.nil?
23
+ end
24
+ rescue StandardError => e
25
+ JekyllSupport.error_short_trace(@logger, e)
26
+ # JekyllSupport.warn_short_trace(@logger, e)
27
+ end
28
+
29
+ # Yes, all_collections is defined for this hook
30
+ # Jekyll::Hooks.register(:site, :post_read, priority: :low) do |site|
31
+ # defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
32
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :low: #{defined_msg}" }
33
+ # rescue StandardError => e
34
+ # JekyllSupport.error_short_trace(@logger, e)
35
+ # # JekyllSupport.warn_short_trace(@logger, e)
36
+ # end
37
+
38
+ # Yes, all_collections is defined for this hook
39
+ # Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
40
+ # defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
41
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
42
+ # rescue StandardError => e
43
+ # JekyllSupport.error_short_trace(@logger, e)
44
+ # # JekyllSupport.warn_short_trace(@logger, e)
45
+ # end
46
+
47
+ # Yes, all_collections is defined for this hook
48
+ # Jekyll::Hooks.register(:site, :pre_render, priority: :normal) do |site, _payload|
49
+ # defined_msg = ::AllCollectionsHooks.all_collections_defined?(site)
50
+ # @logger.debug { "Jekyll::Hooks.register(:site, :pre_render: #{defined_msg}" }
51
+ # rescue StandardError => e
52
+ # JekyllSupport.error_short_trace(@logger, e)
53
+ # # JekyllSupport.warn_short_trace(@logger, e)
54
+ # end
55
+ end
56
+ end
57
+
58
+ PluginMetaLogger.instance.logger.info do
59
+ "Loaded AllCollectionsHooks v#{JekyllPluginSupportVersion::VERSION} :site, :pre_render, :normal hook plugin."
60
+ end
61
+ Liquid::Template.register_filter(JekyllAllCollections::AllCollectionsHooks)
@@ -0,0 +1,48 @@
1
+ # Insert the url of a Jekyll::Page into each LruFile instance,
2
+ # along with the Page reference
3
+ # todo: replace references to url and :url with reverse_url and :reverse_url
4
+ LruFile = Struct.new(:url, :page) do
5
+ include SendChain
6
+
7
+ def <=>(other)
8
+ url <=> other.url
9
+ end
10
+
11
+ def href
12
+ url.reverse
13
+ end
14
+ end
15
+
16
+ # Matches suffixes of an array of urls
17
+ # Converts suffixes to prefixes
18
+ class SortedLruFiles
19
+ attr_reader :msbs
20
+
21
+ def initialize
22
+ @msbs = MSlinnBinarySearch.new %i[url start_with?]
23
+ end
24
+
25
+ # @param apages [Array[APage]]
26
+ def add_pages(apages)
27
+ apages.each { |apage| insert apage.href, apage }
28
+ @msbs.enable_search
29
+ end
30
+
31
+ def enable_search
32
+ @msbs.enable_search
33
+ end
34
+
35
+ def find(suffix)
36
+ @msbs.find suffix
37
+ end
38
+
39
+ def insert(url, file)
40
+ lru_file = LruFile.new(url.reverse, file)
41
+ lru_file.new_chain [:url, %i[start_with? placeholder]]
42
+ @msbs.insert(lru_file)
43
+ end
44
+
45
+ def select(suffix)
46
+ @msbs.select_pages suffix
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ module AllCollectionsHooks
2
+ class << self
3
+ attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files
4
+ end
5
+
6
+ # @sort_by = ->(apages, criteria) { [apages.sort(criteria)] } # todo delete this
7
+
8
+ # @return [String] indicating if :all_collections is defined or not
9
+ def self.all_collections_defined?(site)
10
+ "site.all_collections #{site.class.method_defined?(:all_collections) ? 'IS' : 'IS NOT'} defined"
11
+ end
12
+
13
+ # Create Array of AllCollectionsHooks::APage from objects
14
+ # @param objects [Array] An array of Jekyll::Document, Jekyll::Page or file names
15
+ # @param origin [String] Indicates type of objects being passed
16
+ def self.apages_from_objects(objects, origin)
17
+ pages = []
18
+ objects.each do |object|
19
+ page = APage.new(object, origin)
20
+ pages << page unless page.data['exclude_from_all'] || page.path == 'redirect.html'
21
+ end
22
+ pages
23
+ end
24
+
25
+ # Called by early, high-priority hook.
26
+ # Computes site.all_collections, site.all_documents, site.everything, and site.sorted_lru_files
27
+ def self.compute(site)
28
+ site.class.module_eval { attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files }
29
+
30
+ documents = site.collections
31
+ .values
32
+ .map { |x| x.class.method_defined?(:docs) ? x.docs : x }
33
+ .flatten
34
+ .compact
35
+ @all_collections = AllCollectionsHooks.apages_from_objects(documents, 'collection')
36
+ @all_documents = @all_collections +
37
+ AllCollectionsHooks.apages_from_objects(site.pages, 'individual_page')
38
+ @everything = @all_documents +
39
+ AllCollectionsHooks.apages_from_objects(site.static_files, 'static_file')
40
+ @sorted_lru_files = SortedLruFiles.new.add_pages @everything
41
+
42
+ site.all_collections = @all_collections
43
+ site.all_documents = @all_documents
44
+ site.everything = @everything
45
+ site.sorted_lru_files = @sorted_lru_files
46
+ rescue StandardError => e
47
+ JekyllSupport.error_short_trace(::JekyllAllCollections::AllCollectionsHooks.logger, e)
48
+ # JekyllSupport.warn_short_trace(::JekyllAllCollections::AllCollectionsHooks.logger, e)
49
+ end
50
+ end
@@ -0,0 +1,157 @@
1
+ # require 'jekyll_draft'
2
+ require 'securerandom'
3
+
4
+ # @author Copyright 2020 Michael Slinn
5
+ # @license SPDX-License-Identifier: Apache-2.0
6
+ module JekyllAllCollections
7
+ PLUGIN_NAME = 'all_collections'.freeze unless defined?(PLUGIN_NAME)
8
+ CRITERIA = %w[date destination draft label last_modified last_modified_at path relative_path title type url].freeze unless defined?(CRITERIA)
9
+ DRAFT_HTML = '<i class="jekyll_draft">Draft</i>'.freeze unless defined?(DRAFT_HTML)
10
+
11
+ class AllCollectionsTag < ::JekyllSupport::JekyllTag
12
+ include ::JekyllPluginSupportVersion
13
+
14
+ # Method prescribed by JekyllTag.
15
+ # @return [String]
16
+ def render_impl
17
+ parse_arguments # Defines instance variables like @sort_by
18
+ sort_lambda = init_sort_by @sort_by, @sort_by_param
19
+ @heading = @helper.parameter_specified?('heading') || default_head(@sort_by)
20
+ generate_output sort_lambda
21
+ rescue StandardError => e
22
+ JekyllSupport.error_short_trace @logger, e
23
+ # JekyllSupport.warn_short_trace @logger, e
24
+ end
25
+
26
+ # Descending sort keys reverse the order of comparison
27
+ # Example return values:
28
+ # "->(a, b) { [a.last_modified] <=> [b.last_modified] }"
29
+ # "->(a, b) { [b.last_modified] <=> [a.last_modified] }" (descending)
30
+ # "->(a, b) { [a.last_modified, a.date] <=> [b.last_modified, b.date] }" (descending last_modified, ascending date)
31
+ # "->(a, b) { [a.last_modified, b.date] <=> [b.last_modified, a.date] }" (ascending last_modified, descending date)
32
+ def self.create_lambda_string(criteria)
33
+ criteria_lhs_array = []
34
+ criteria_rhs_array = []
35
+ verify_sort_by_type(criteria).each do |c|
36
+ descending_sort = c.start_with? '-'
37
+ c.delete_prefix! '-'
38
+ abort("Error: '#{c}' is not a valid sort field. Valid field names are: #{CRITERIA.join ', '}") \
39
+ unless CRITERIA.include?(c)
40
+ criteria_lhs_array << (descending_sort ? "b.#{c}" : "a.#{c}")
41
+ criteria_rhs_array << (descending_sort ? "a.#{c}" : "b.#{c}")
42
+ end
43
+ "->(a, b) { [#{criteria_lhs_array.join(', ')}] <=> [#{criteria_rhs_array.join(', ')}] }"
44
+ end
45
+
46
+ def self.verify_sort_by_type(sort_by)
47
+ case sort_by
48
+ when Array
49
+ sort_by
50
+ when Enumerable
51
+ sort_by.to_a
52
+ when Date
53
+ [sort_by.to_i]
54
+ when String
55
+ [sort_by]
56
+ else
57
+ abort "Error: @sort_by was specified as '#{sort_by}'; it must either be a string or an array of strings"
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def default_head(sort_by)
64
+ criteria = (sort_by.map do |x|
65
+ reverse = x.start_with? '-'
66
+ criterion = x.delete_prefix('-').capitalize
67
+ criterion += reverse ? ' (Newest to Oldest)' : ' (Oldest to Newest)'
68
+ criterion
69
+ end).join(', ')
70
+ "All Posts in All Categories Sorted By #{criteria}"
71
+ end
72
+
73
+ def evaluate(string)
74
+ self.eval string, binding, __FILE__, __LINE__
75
+ rescue StandardError => e
76
+ warn_short_trace e.red
77
+ end
78
+
79
+ def last_modified_value(apage)
80
+ @logger.debug do
81
+ " apage.last_modified='#{apage.last_modified}'; " \
82
+ "apage.last_modified_at='#{apage.last_modified_at}'; " \
83
+ "@date_column='#{@date_column}'"
84
+ end
85
+ last_modified = if @date_column == 'last_modified' && apage.respond_to?(:last_modified)
86
+ apage.last_modified
87
+ elsif apage.respond_to? :last_modified_at
88
+ apage.last_modified_at
89
+ else
90
+ apage.date
91
+ end
92
+ last_modified ||= apage.date || Date.today
93
+ last_modified
94
+ end
95
+
96
+ def generate_output(sort_lambda)
97
+ id = @id.to_s.strip.empty? ? '' : " id='#{@id}'"
98
+ heading = @heading.strip.to_s.empty? ? '' : "<h2#{id}>#{@heading}</h2>"
99
+ data = case @data_selector
100
+ when 'all_collections'
101
+ @site.all_collections
102
+ when 'all_documents'
103
+ @site.all_documents
104
+ when 'everything'
105
+ @site.everything
106
+ else
107
+ raise AllCollectionsError, "Invalid value for @data_selector (#{data_selector})"
108
+ end
109
+ collection = data.sort(&sort_lambda)
110
+ posts = collection.map do |x|
111
+ last_modified = last_modified_value x
112
+ date = last_modified.strftime '%Y-%m-%d'
113
+ draft = x.draft ? DRAFT_HTML : ''
114
+ href = "<a href='#{x.href}'>#{x.title}</a>"
115
+ @logger.debug { " date='#{date}' #{x.title}\n" }
116
+ " <span>#{date}</span><span>#{href}#{draft}</span>"
117
+ end
118
+ <<~END_TEXT
119
+ #{heading}
120
+ <div class="posts">
121
+ #{posts.join "\n"}
122
+ </div>
123
+ END_TEXT
124
+ rescue ArgumentError => e
125
+ warn_short_trace e
126
+ end
127
+
128
+ # See https://stackoverflow.com/a/75377832/553865
129
+ def init_sort_by(sort_by, sort_by_param)
130
+ sort_lambda_string = AllCollectionsTag.create_lambda_string sort_by
131
+
132
+ @logger.debug do
133
+ "#{@page['path']} sort_by_param=#{sort_by_param} " \
134
+ "sort_lambda_string = #{sort_lambda_string}\n"
135
+ end
136
+
137
+ evaluate sort_lambda_string
138
+ end
139
+
140
+ def parse_arguments
141
+ @data_selector = @helper.parameter_specified?('data_selector') || 'all_collections'
142
+ abort "Invalid data_selector #{@data_selector}" unless %w[all_collections all_documents everything].include? @data_selector
143
+
144
+ @date_column = @helper.parameter_specified?('date_column') || 'date'
145
+ unless %w[date last_modified].include?(@date_column)
146
+ raise AllCollectionsError "The date_column attribute must either have value 'date' or 'last_modified', " \
147
+ "but '#{@date_column}' was specified"
148
+ end
149
+
150
+ @id = @helper.parameter_specified?('id') || SecureRandom.hex(10)
151
+ @sort_by_param = @helper.parameter_specified? 'sort_by'
152
+ @sort_by = (@sort_by_param&.delete(' ')&.split(',') if @sort_by_param != false) || ['-date']
153
+ end
154
+
155
+ ::JekyllSupport::JekyllPluginHelper.register(self, PLUGIN_NAME)
156
+ end
157
+ end
@@ -1,5 +1,3 @@
1
- require_relative '../error/jekyll_custom_error'
2
-
3
1
  # Monkey patch StandardError so a new method called shorten_backtrace is added.
4
2
  class StandardError
5
3
  def shorten_backtrace(backtrace_element_count = 3)
@@ -12,7 +10,7 @@ class StandardError
12
10
  end
13
11
 
14
12
  module JekyllSupport
15
- DISPLAYED_CALLS = 8
13
+ DISPLAYED_CALLS = 8 unless defined?(DISPLAYED_CALLS)
16
14
 
17
15
  def self.error_short_trace(logger, error)
18
16
  error.set_backtrace error.backtrace[0..DISPLAYED_CALLS]
@@ -22,11 +20,11 @@ module JekyllSupport
22
20
 
23
21
  # @return a new StandardError subclass containing the shorten_backtrace method
24
22
  def define_error
25
- Class.new JekyllSupport::CustomError
23
+ Class.new ::JekyllSupport::CustomError
26
24
  end
27
25
  module_function :define_error
28
26
 
29
- JekyllPluginSupportError = define_error
27
+ JekyllPluginSupportError = define_error unless defined?(JekyllPluginSupportError)
30
28
 
31
29
  def self.dump_vars(_logger, liquid_context)
32
30
  page = liquid_context.registers[:page]
@@ -1,6 +1,4 @@
1
- require 'jekyll'
2
-
3
- Registers = Struct.new(:page, :site)
1
+ Registers = Struct.new(:page, :site) unless defined?(Registers)
4
2
 
5
3
  # Mock for Collections
6
4
  class Collections
@@ -1,3 +1,3 @@
1
1
  module JekyllPluginSupportVersion
2
- VERSION = '1.1.0'.freeze
2
+ VERSION = '3.0.0'.freeze unless defined?(VERSION)
3
3
  end
@@ -1,13 +1,26 @@
1
- require 'colorator'
2
- require 'jekyll'
3
- require 'jekyll_plugin_logger'
4
-
5
1
  def require_directory(dir)
6
2
  Dir[File.join(dir, '*.rb')]&.sort&.each do |file|
7
3
  require file unless file == __FILE__
8
4
  end
9
5
  end
10
6
 
7
+ require 'colorator'
8
+ require 'jekyll'
9
+ require 'jekyll_plugin_logger'
10
+ require 'pry'
11
+ require 'sorted_set'
12
+
13
+ # require_directory __dir__
14
+ require_directory "#{__dir__}/util"
15
+ require_directory "#{__dir__}/error"
16
+ require_directory "#{__dir__}/block"
17
+ require_directory "#{__dir__}/generator"
18
+ require_directory "#{__dir__}/helper"
19
+ require_directory "#{__dir__}/jekyll_plugin_support"
20
+ require_directory "#{__dir__}/tag"
21
+ require_directory "#{__dir__}/jekyll_all_collections"
22
+ require_directory "#{__dir__}/hooks"
23
+
11
24
  module JekyllSupport
12
25
  def self.redef_without_warning(const, value)
13
26
  send(:remove_const, const) if const_defined?(const)
@@ -21,14 +34,6 @@ module NoArgParsing
21
34
  @no_arg_parsing = true
22
35
  end
23
36
 
24
- require_directory __dir__
25
- require_directory "#{__dir__}/block"
26
- require_directory "#{__dir__}/error"
27
- require_directory "#{__dir__}/generator"
28
- require_directory "#{__dir__}/helper"
29
- require_directory "#{__dir__}/jekyll_plugin_support"
30
- require_directory "#{__dir__}/tag"
31
-
32
37
  module JekyllSupport
33
38
  class JekyllTag
34
39
  include JekyllSupportError
@@ -1,6 +1,3 @@
1
- require 'pry'
2
- require_relative '../error/jekyll_plugin_error_handling'
3
-
4
1
  module JekyllSupport
5
2
  # Base class for Jekyll tags
6
3
  class JekyllTag < Liquid::Tag
@@ -36,7 +33,7 @@ module JekyllSupport
36
33
  @helper = JekyllPluginHelper.new(tag_name, @argument_string, @logger, respond_to?(:no_arg_parsing))
37
34
 
38
35
  @error_name = "#{tag_name.camelcase(:upper)}Error"
39
- JekyllSupport::CustomError.factory @error_name
36
+ ::JekyllSupport::CustomError.factory @error_name
40
37
  end
41
38
 
42
39
  # Method prescribed by the Jekyll plugin lifecycle.
@@ -1,5 +1,3 @@
1
- require_relative '../error/jekyll_plugin_error_handling'
2
-
3
1
  module JekyllSupport
4
2
  class JekyllTagNoArgParsing < JekyllTag
5
3
  attr_reader :argument_string, :helper, :line_number, :logger, :page, :site
@@ -0,0 +1,152 @@
1
+ unless defined?(MSlinnBinarySearchError)
2
+ class MSlinnBinarySearchError < StandardError
3
+ end
4
+ end
5
+
6
+ # Ruby's binary search is unsuitable because the value to be searched for changes the required ordering for String compares
7
+ class MSlinnBinarySearch
8
+ attr_reader :accessor_chain, :array # For testing only
9
+
10
+ def initialize(accessor_chain)
11
+ @array = SortedSet.new # [LruFile] Ordered highest to lowest
12
+ @accessor_chain = accessor_chain
13
+ end
14
+
15
+ # Convert the SortedSet to an Array
16
+ def enable_search
17
+ @array = @array.to_a
18
+ end
19
+
20
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
21
+ # @param stem [String]
22
+ # @return first item from @array.url that matches, or nil if no match
23
+ def find(stem)
24
+ raise MSlinnBinarySearchError, 'Invalid find because stem to search for is nil.' if stem.nil?
25
+
26
+ index = find_index(stem)
27
+ return nil if index.nil?
28
+
29
+ @array[index]
30
+ end
31
+
32
+ # @param stem [String]
33
+ # @return index of first matching stem, or nil if @array is empty, or 0 if no stem specified
34
+ def find_index(stem)
35
+ return nil if @array.empty?
36
+ return 0 if stem.nil? || stem.empty?
37
+
38
+ mets = stem.reverse
39
+ return nil if @array[0].url[0...mets.size] > mets # TODO: use chain eval for item
40
+ return nil if @array[0].url[0] != mets[0]
41
+
42
+ _find_index(mets, 0, @array.length - 1)
43
+ end
44
+
45
+ # @param stem [String]
46
+ # @return [index] of matching values, or [] if @array is empty, or entire array if no stem specified
47
+ def find_indices(stem)
48
+ return [] if @array.empty?
49
+ return @array if stem.nil? || stem.empty?
50
+
51
+ first_index = _find_index(stem, 0, @array.length - 1)
52
+ last_index = first_index
53
+ last_index += 1 while @array[last_index].url.start_with? stem
54
+ [first_index..last_index]
55
+ end
56
+
57
+ # @param item [LruFile]
58
+ # @return [int] index of matching LruFile in @array, or nil if not found
59
+ def index_of(lru_file)
60
+ raise MSlinnBinarySearchError, 'Invalid index_of lru_file (nil).' if lru_file.nil?
61
+
62
+ find_index lru_file.url
63
+ end
64
+
65
+ # @return [LruFile] item at given index in @array
66
+ def item_at(index)
67
+ if index > @array.length - 1
68
+ raise MSlinnBinarySearchError,
69
+ "Invalid item_at index (#{index}) is greater than maximum stem (#{@array.length - 1})."
70
+ end
71
+ raise MSlinnBinarySearchError, "Invalid item_at index (#{index}) is less than zero." if index.negative?
72
+
73
+ @array[index]
74
+ end
75
+
76
+ # @param lru_file [LruFile]
77
+ def insert(lru_file)
78
+ raise MSlinnBinarySearchError, 'Invalid insert because new item is nil.' if lru_file.nil?
79
+ raise MSlinnBinarySearchError, "Invalid insert because new item has no chain (#{lru_file})" if lru_file.chain.nil?
80
+
81
+ @array.add lru_file
82
+ end
83
+
84
+ # TODO: Cache this method
85
+ # @param suffix [String] to use stem search on
86
+ # @return nil if @array is empty
87
+ # @return the first item in @array if suffix is nil or an empty string
88
+ def prefix_search(suffix)
89
+ return nil if @array.empty?
90
+ return @array[0] if suffix.empty? || suffix.nil?
91
+
92
+ low = search_index { |x| x.evaluate_with suffix }
93
+ return [] if low.nil?
94
+
95
+ high = low
96
+ high += 1 while high < @array.length &&
97
+ @array[high].evaluate_with(suffix)
98
+ @array[low..high]
99
+ end
100
+
101
+ # @param stem [String]
102
+ # @return [APage] matching APages, or [] if @array is empty, or entire array if no stem specified
103
+ def select_pages(stem)
104
+ first_index = find_index stem
105
+ return [] if first_index.nil?
106
+
107
+ last_index = first_index
108
+ while last_index < @array.length - 1
109
+ # LruFile.url is reversed, bug LruFile.page is not
110
+ break unless @array[last_index + 1].url.start_with?(stem.reverse)
111
+
112
+ last_index += 1
113
+ end
114
+ Range.new(first_index, last_index).map { |i| @array[i].page }
115
+ end
116
+
117
+ private
118
+
119
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
120
+ # @param stem [String]
121
+ # @return [int] first index in @array that matches, or nil if no match
122
+ def _find_index(mets, min_index, max_index)
123
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})<0" if min_index.negative?
124
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})>max_index(#{max_index})" if min_index > max_index
125
+ raise MSlinnBinarySearchError, "_find_index max_index(#{max_index})>=@array.length(#{@array.length})" if max_index >= @array.length
126
+
127
+ return min_index if (min_index == max_index) && @array[min_index].url.start_with?(mets)
128
+
129
+ while min_index < max_index
130
+ mid_index = (min_index + max_index) / 2
131
+ mid_value = @array[mid_index].url[0...(mets.size)] # TODO: use chain eval for item
132
+
133
+ if mid_value == mets # back up until the first match is found
134
+ index = mid_index
135
+ loop do
136
+ return 0 if index.zero?
137
+
138
+ return index unless @array[index - 1].url.start_with?(mets)
139
+
140
+ index -= 1
141
+ end
142
+ elsif mid_value > mets
143
+ max_index = mid_index - 1
144
+ return _find_index(mets, min_index, max_index)
145
+ else
146
+ min_index = mid_index + 1
147
+ return _find_index(mets, min_index, max_index)
148
+ end
149
+ end
150
+ nil
151
+ end
152
+ end