lib/sass/plugin/staleness_checker.rb



module Sass
  module Plugin
    # The class handles `.s[ca]ss` file staleness checks via their mtime timestamps.
    #
    # To speed things up two level of caches are employed:
    #
    # * A class-level dependency cache which stores @import paths for each file.
    #   This is a long-lived cache that is reused by every StalenessChecker instance.
    # * Two short-lived instance-level caches, one for file mtimes
    #   and one for whether a file is stale during this particular run.
    #   These are only used by a single StalenessChecker instance.
    #
    # Usage:
    #
    # * For a one-off staleness check of a single `.s[ca]ss` file,
    #   the class-level {stylesheet_needs_update?} method
    #   should be used.
    # * For a series of staleness checks (e.g. checking all files for staleness)
    #   a StalenessChecker instance should be created,
    #   and the instance-level \{#stylesheet\_needs\_update?} method should be used.
    #   the caches should make the whole process significantly faster.
    #   *WARNING*: It is important not to retain the instance for too long,
    #   as its instance-level caches are never explicitly expired.
    class StalenessChecker
      DELETED             = 1.0/0.0 # positive Infinity
      @dependencies_cache = {}

      class << self
        # @private
        attr_accessor :dependencies_cache
      end

      # Creates a new StalenessChecker
      # for checking the staleness of several stylesheets at once.
      def initialize
        @dependencies = self.class.dependencies_cache

        # Entries in the following instance-level caches are never explicitly expired.
        # Instead they are supposed to automaticaly go out of scope when a series of staleness checks
        # (this instance of StalenessChecker was created for) is finished.
        @mtimes, @dependencies_stale = {}, {}
      end

      # Returns whether or not a given CSS file is out of date
      # and needs to be regenerated.
      #
      # @param css_file [String] The location of the CSS file to check.
      # @param template_file [String] The location of the Sass or SCSS template
      #   that is compiled to `css_file`.
      def stylesheet_needs_update?(css_file, template_file)
        template_file, css_mtime = File.expand_path(template_file), mtime(css_file)

        css_mtime == DELETED || dependency_updated?(css_mtime).call(template_file)
      end

      # Returns whether or not a given CSS file is out of date
      # and needs to be regenerated.
      #
      # The distinction between this method and the instance-level \{#stylesheet\_needs\_update?}
      # is that the instance method preserves mtime and stale-dependency caches,
      # so it's better to use when checking multiple stylesheets at once.
      #
      # @param css_file [String] The location of the CSS file to check.
      # @param template_file [String] The location of the Sass or SCSS template
      #   that is compiled to `css_file`.
      def self.stylesheet_needs_update?(css_file, template_file)
        new.stylesheet_needs_update?(css_file, template_file)
      end

      private

      def dependencies_stale?(template_file, css_mtime)
        timestamps = @dependencies_stale[template_file] ||= {}
        timestamps.each_pair do |checked_css_mtime, is_stale|
          if checked_css_mtime <= css_mtime && !is_stale
            return false
          elsif checked_css_mtime > css_mtime && is_stale
            return true
          end
        end
        timestamps[css_mtime] = dependencies(template_file).any?(&dependency_updated?(css_mtime))
      end

      def mtime(filename)
        @mtimes[filename] ||= begin
          File.mtime(filename).to_i
        rescue Errno::ENOENT
          @dependencies.delete(filename)
          DELETED
        end
      end

      def dependencies(filename)
        stored_mtime, dependencies = @dependencies[filename]

        if !stored_mtime || stored_mtime < mtime(filename)
          @dependencies[filename] = [mtime(filename), dependencies = compute_dependencies(filename)]
        end

        dependencies
      end

      def dependency_updated?(css_mtime)
        lambda do |dep|
          begin
            mtime(dep) > css_mtime || dependencies_stale?(dep, css_mtime)
          rescue Sass::SyntaxError
            # If there's an error finding depenencies, default to recompiling.
            true
          end
        end
      end

      def compute_dependencies(filename)
        Files.tree_for(filename, Plugin.engine_options).grep(Tree::ImportNode) do |n|
          File.expand_path(n.full_filename) unless n.full_filename =~ /\.css$/
        end.compact
      rescue Sass::SyntaxError => e
        [] # If the file has an error, we assume it has no dependencies
      end
    end
  end
end