lib/spontaneous/loader.rb
# encoding: UTF-8
require 'pathname'
module Spontaneous
class Loader
attr_reader :use_reloader, :load_paths
alias_method :use_reloader?, :use_reloader
def initialize(load_paths, use_reloader)
@load_paths = load_paths
@use_reloader = use_reloader
end
def reloader
@reloader ||= Reloader.new(load_paths)
end
def load!
reloader.run! if use_reloader?
load_paths.each do |path|
load_classes(path)
end
end
def reload!
reloader.reload!
end
def load_classes(*paths)
orphaned_classes = []
paths.flatten.each do |path|
Dir[path].sort.each do |file|
begin
load_file(file)
rescue NameError => ne
# puts "Stashed file with missing requirements for later reloading: #{file}"
# ne.backtrace.each_with_index { |line, idx| puts "[#{idx}]: #{line}" }
orphaned_classes.unshift(file)
end
end
end
load_classes_with_requirements(orphaned_classes)
end
def load_file(file)
if use_reloader?
reloader.safe_load(file)
else
require(file)
end
end
def load_classes_with_requirements(klasses)
klasses.uniq!
while klasses.size > 0
# Note size to make sure things are loading
size_at_start = klasses.size
# List of failed classes
failed_classes = []
# Map classes to exceptions
error_map = {}
klasses.each do |klass|
begin
load_file klass
rescue NameError => ne
error_map[klass] = ne
failed_classes.push(klass)
end
end
klasses.clear
# Keep list of classes unique
failed_classes.each { |k| klasses.push(k) unless klasses.include?(k) }
# Stop processing if nothing loads or if everything has loaded
if klasses.size == size_at_start && klasses.size != 0
# Write all remaining failed classes and their exceptions to the log
messages = error_map.map do |klass, e|
backtrace = (e.backtrace.select { |line| is_site_page?(line) } || []).join("\n") << "\n..."
["Could not load #{klass}:\n\n#{e.message} - (#{e.class})", "#{ backtrace }"]
end
messages.each { |msg, trace| logger.fatal("#{msg}\n\n#{trace}") }
abort("\n#{failed_classes.join(", ")} failed to load.")
end
break if(klasses.size == size_at_start || klasses.size == 0)
end
nil
end
def is_site_page?(backtrace)
path, line, context = backtrace.split(":")
path = File.expand_path(path)
root = Spontaneous::Site.instance.root
path.start_with?(root)
end
class Reloader
attr_reader :load_paths, :cache, :mtimes, :files_loaded, :loaded_classes
def initialize(load_paths)
@load_paths = load_paths
@cache = {}
@mtimes = {}
@files_loaded = {}
@loaded_classes = Hash.new { |h, k| h[k] = [] }
end
def reset!
cache.clear
mtimes.clear
files_loaded.clear
loaded_classes.clear
end
def reload!
rotation do |file, mtime|
# Retrive the last modified time
new_file = mtimes[file].nil?
previous_mtime = mtimes[file] ||= mtime
logger.debug "Detected a new file #{file}" if new_file
# We skip to next file if it is not new and not modified
next unless new_file || mtime > previous_mtime
# Now we can reload our file
safe_load(file, mtime)
end
end
##
# Returns true if any file changes are detected and populates the MTIMES cache
#
def changed?
changed = false
rotation do |file, mtime|
new_file = mtimes[file].nil?
previous_mtime = mtimes[file] ||= mtime
changed = true if new_file || mtime > previous_mtime
end
changed
end
alias :run! :changed?
def dependency_file?(file)
true
end
def classes_for_file(file)
loaded_classes[file]
end
def file_for_class(klass)
loaded_classes.select do |file, classes|
classes.include?(klass)
end.keys
end
##
# A safe Kernel::load which issues the necessary hooks depending on results
#
def safe_load(file, mtime=nil)
reload = mtime && mtime > mtimes[file]
logger.debug "Reloading #{relativize_path(file)}" if reload
# Removes all classes declared in the specified file
if klasses = loaded_classes.delete(file)
klasses.each { |klass| remove_constant(klass) }
end
# Keeps track of which constants were loaded and the files
# that have been added so that the constants can be removed
# and the files can be removed from $LOADED_FEAUTRES
if self.files_loaded[file]
self.files_loaded[file].each do |fl|
next if fl == file
$LOADED_FEATURES.delete(fl) if dependency_file?(fl)
end
end
# Now reload the file ignoring any syntax errors
$LOADED_FEATURES.delete(file)
# Duplicate objects and loaded features in the file
klasses = ObjectSpace.classes.dup
modules = ObjectSpace.modules.dup
already_loaded = $LOADED_FEATURES.dup
# Start to re-require old dependencies
#
# Why we need to reload the dependencies i.e. of a model?
#
# In some circumstances (i.e. with MongoMapper) reloading a model require:
#
# 1) Clean objectspace
# 2) Reload model dependencies
#
# We need to clean objectspace because for example we don't need to apply two times validations keys etc...
#
# We need to reload MongoMapper dependencies for re-initialize them.
#
# In other cases i.e. in a controller (specially with dependencies that uses autoload) reload stuff like sass
# is not really necessary... but how to distinguish when it is (necessary) since it is not?
#
if self.files_loaded[file]
self.files_loaded[file].each do |fl|
next if fl == file
# Swich off for a while warnings expecially "already initialized constant" stuff
begin
verbosity = $-v
$-v = nil
require(fl)
ensure
$-v = verbosity
end
end
end
# And finally reload the specified file
begin
require(file)
rescue => e
# logger.info "Failed to load '#{relativize_path(file)}' : #{e.message}"
raise e
ensure
# Ensure that any classes loaded by the file before it failed get
# added to the list of loaded classes for a file.
# Without this a long file with an error will only record classes
# loaded after the error has been resolved.
new_objects = (ObjectSpace.classes - klasses) + (ObjectSpace.modules - modules)
loaded_classes[file].concat(new_objects)
mtimes[file] = mtime if mtime
end
# Store the file details after successful loading
self.files_loaded[file] = $LOADED_FEATURES - already_loaded
loaded_classes[file]
end
def relativize_path(path)
pwd = Pathname.new(Dir.pwd)
Pathname.new(path).relative_path_from(pwd)
end
##
# Removes the specified class and constant.
#
def remove_constant(const)
const.__finalize if const.respond_to?(:__finalize)
Spontaneous.schema.delete(const)
parts = const.to_s.split("::")
begin
base = parts.size == 1 ? Object : Object.full_const_get(parts[0..-2].join("::"))
object = parts[-1].to_s
base.send(:remove_const, object)
rescue NameError => e
# logger.warn(e)
end
nil
end
##
# Searches Ruby files in your load_paths and monitors them for any changes.
#
def rotation
paths = []
files = load_paths.map do |glob|
Dir[glob]
end.flatten.uniq
files.map { |file|
found, stat = figure_path(file, paths)
next unless found && stat && mtime = stat.mtime
cache[file] = found
yield(found, mtime)
}.compact
end
##
# Takes a relative or absolute +file+ name and a couple possible +paths+ that
# the +file+ might reside in. Returns the full path and File::Stat for that path.
#
def figure_path(file, paths)
found = cache[file]
found = file if !found and Pathname.new(file).absolute?
found, stat = safe_stat(found)
return found, stat if found
# paths.find do |possible_path|
# path = ::File.join(possible_path, file)
# found, stat = safe_stat(path)
# return ::File.expand_path(found), stat if found
# end
return false, false
end
def safe_stat(file)
return unless file
stat = ::File.stat(file)
return file, stat if stat.file?
rescue Errno::ENOENT, Errno::ENOTDIR
cache.delete(file) and false
end
end
end # Loader
class SchemaLoader < Loader
def reloader
@reloader ||= SchemaReloader.new(load_paths)
end
class SchemaReloader < Loader::Reloader
def schema_classes_for_file(file)
if klasses = classes_for_file(file)
klasses.select { |c| is_schema_class?(c) }
else
[]
end
end
# Because the schema classes are so tied up together reloading
# must be done in the right order.
# This method works by finding all the modified files then
# mapping those to modified classes. Using the Schema, we find
# all the subclasses affected by the modified file and map those
# to files.
# To reload these we must first reload the superclass, otherwise
# the subclasses will re-load but load schema definitions from the
# already loaded but out-of-date superclass and we'll end up
# with orphaned box and field definitions
def reload!
changed_files = []
rotation do |file, mtime|
# Retrive the last modified time
new_file = mtimes[file].nil?
previous_mtime = mtimes[file] ||= mtime
logger.debug "Detected a new file #{file}" if new_file
# We skip to next file if it is not new and not modified
changed_files << [file, mtime] if new_file || mtime > previous_mtime
# Now we can reload our file
end
all_classes = schema.classes.dup
modified_classes = changed_files.map do |file, mtime|
schema_classes_for_file(file)
end.flatten
affected_subclasses = modified_classes.map do |modified_class|
all_classes.select { |schema_class|
schema_class < modified_class
}
end.flatten
subclass_files_to_reload = affected_subclasses.map do |subclass|
file_for_class(subclass)
end.flatten
changed_files.each { |file, mtime|
begin
safe_load(file, mtime)
rescue => boom
# remove the file so that it gets reloaded next time, this stops errors in the schema files
# from resulting in a schema map change...
mtimes.delete(file)
raise boom
end
}
subclass_files_to_reload.each { |file| safe_load(file) }
end
def schema
Spontaneous.schema
end
def is_schema_class?(klass)
(klass < schema.site.model or klass < schema.site.model::Box)
end
def remove_constant(const)
super if is_schema_class?(const)
end
def dependency_file?(file)
path = ::File.expand_path(file)
tests = load_paths.map { |load_path| ::File.fnmatch?(load_path, path) }
tests.any? { |t| t }
end
end
end
end # Spontaneous