index.rb 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. require 'pathname'
  2. module Hike
  3. # `Index` is an internal cached variant of `Trail`. It assumes the
  4. # file system does not change between `find` calls. All `stat` and
  5. # `entries` calls are cached for the lifetime of the `Index` object.
  6. class Index
  7. # `Index#paths` is an immutable `Paths` collection.
  8. attr_reader :paths
  9. # `Index#extensions` is an immutable `Extensions` collection.
  10. attr_reader :extensions
  11. # `Index#aliases` is an immutable `Hash` mapping an extension to
  12. # an `Array` of aliases.
  13. attr_reader :aliases
  14. # `Index.new` is an internal method. Instead of constructing it
  15. # directly, create a `Trail` and call `Trail#index`.
  16. def initialize(root, paths, extensions, aliases)
  17. @root = root
  18. # Freeze is used here so an error is throw if a mutator method
  19. # is called on the array. Mutating `@paths`, `@extensions`, or
  20. # `@aliases` would have unpredictable results.
  21. @paths = paths.dup.freeze
  22. @extensions = extensions.dup.freeze
  23. @aliases = aliases.inject({}) { |h, (k, a)|
  24. h[k] = a.dup.freeze; h
  25. }.freeze
  26. @pathnames = paths.map { |path| Pathname.new(path) }
  27. @stats = {}
  28. @entries = {}
  29. @patterns = {}
  30. end
  31. # `Index#root` returns root path as a `String`. This attribute is immutable.
  32. def root
  33. @root.to_s
  34. end
  35. # `Index#index` returns `self` to be compatable with the `Trail` interface.
  36. def index
  37. self
  38. end
  39. # The real implementation of `find`. `Trail#find` generates a one
  40. # time index and delegates here.
  41. #
  42. # See `Trail#find` for usage.
  43. def find(*logical_paths, &block)
  44. if block_given?
  45. options = extract_options!(logical_paths)
  46. base_path = Pathname.new(options[:base_path] || @root)
  47. logical_paths.each do |logical_path|
  48. logical_path = Pathname.new(logical_path.sub(/^\//, ''))
  49. if relative?(logical_path)
  50. find_in_base_path(logical_path, base_path, &block)
  51. else
  52. find_in_paths(logical_path, &block)
  53. end
  54. end
  55. nil
  56. else
  57. find(*logical_paths) do |path|
  58. return path
  59. end
  60. end
  61. end
  62. # A cached version of `Dir.entries` that filters out `.` files and
  63. # `~` swap files. Returns an empty `Array` if the directory does
  64. # not exist.
  65. def entries(path)
  66. key = path.to_s
  67. @entries[key] ||= Pathname.new(path).entries.reject { |entry| entry.to_s =~ /^\.|~$|^\#.*\#$/ }.sort
  68. rescue Errno::ENOENT
  69. @entries[key] = []
  70. end
  71. # A cached version of `File.stat`. Returns nil if the file does
  72. # not exist.
  73. def stat(path)
  74. key = path.to_s
  75. if @stats.key?(key)
  76. @stats[key]
  77. else
  78. begin
  79. @stats[key] = File.stat(path)
  80. rescue Errno::ENOENT
  81. @stats[key] = nil
  82. end
  83. end
  84. end
  85. protected
  86. def extract_options!(arguments)
  87. arguments.last.is_a?(Hash) ? arguments.pop.dup : {}
  88. end
  89. def relative?(logical_path)
  90. logical_path.to_s =~ /^\.\.?\//
  91. end
  92. # Finds logical path across all `paths`
  93. def find_in_paths(logical_path, &block)
  94. dirname, basename = logical_path.split
  95. @pathnames.each do |base_path|
  96. match(base_path.join(dirname), basename, &block)
  97. end
  98. end
  99. # Finds relative logical path, `../test/test_trail`. Requires a
  100. # `base_path` for reference.
  101. def find_in_base_path(logical_path, base_path, &block)
  102. candidate = base_path.join(logical_path)
  103. dirname, basename = candidate.split
  104. match(dirname, basename, &block) if paths_contain?(dirname)
  105. end
  106. # Checks if the path is actually on the file system and performs
  107. # any syscalls if necessary.
  108. def match(dirname, basename)
  109. # Potential `entries` syscall
  110. matches = entries(dirname)
  111. pattern = pattern_for(basename)
  112. matches = matches.select { |m| m.to_s =~ pattern }
  113. sort_matches(matches, basename).each do |path|
  114. pathname = dirname.join(path)
  115. # Potential `stat` syscall
  116. stat = stat(pathname)
  117. # Exclude directories
  118. if stat && stat.file?
  119. yield pathname.to_s
  120. end
  121. end
  122. end
  123. # Returns true if `dirname` is a subdirectory of any of the `paths`
  124. def paths_contain?(dirname)
  125. paths.any? { |path| dirname.to_s[0, path.length] == path }
  126. end
  127. # Cache results of `build_pattern_for`
  128. def pattern_for(basename)
  129. @patterns[basename] ||= build_pattern_for(basename)
  130. end
  131. # Returns a `Regexp` that matches the allowed extensions.
  132. #
  133. # pattern_for("index.html") #=> /^index(.html|.htm)(.builder|.erb)*$/
  134. def build_pattern_for(basename)
  135. extname = basename.extname
  136. aliases = find_aliases_for(extname)
  137. if aliases.any?
  138. basename = basename.basename(extname)
  139. aliases = [extname] + aliases
  140. aliases_pattern = aliases.map { |e| Regexp.escape(e) }.join("|")
  141. basename_re = Regexp.escape(basename.to_s) + "(?:#{aliases_pattern})"
  142. else
  143. basename_re = Regexp.escape(basename.to_s)
  144. end
  145. extension_pattern = extensions.map { |e| Regexp.escape(e) }.join("|")
  146. /^#{basename_re}(?:#{extension_pattern})*$/
  147. end
  148. # Sorts candidate matches by their extension
  149. # priority. Extensions in the front of the `extensions` carry
  150. # more weight.
  151. def sort_matches(matches, basename)
  152. aliases = find_aliases_for(basename.extname)
  153. matches.sort_by do |match|
  154. extnames = match.sub(basename.to_s, '').to_s.scan(/\.[^.]+/)
  155. extnames.inject(0) do |sum, ext|
  156. if i = extensions.index(ext)
  157. sum + i + 1
  158. elsif i = aliases.index(ext)
  159. sum + i + 11
  160. else
  161. sum
  162. end
  163. end
  164. end
  165. end
  166. def find_aliases_for(extension)
  167. @aliases.inject([]) do |aliases, (key, value)|
  168. aliases.push(key) if value == extension
  169. aliases
  170. end
  171. end
  172. end
  173. end