# typed: strict # frozen_string_literal: true require "digest" module Packwerk class Cache extend T::Sig class CacheContents < T::Struct extend T::Sig const :file_contents_digest, String const :unresolved_references, T::Array[UnresolvedReference] class << self extend T::Sig sig { params(serialized_cache_contents: String).returns(CacheContents) } def deserialize(serialized_cache_contents) cache_contents_json = JSON.parse(serialized_cache_contents) unresolved_references = cache_contents_json["unresolved_references"].map do |json| UnresolvedReference.new( constant_name: json["constant_name"], namespace_path: json["namespace_path"], relative_path: json["relative_path"], source_location: Node::Location.new(json["source_location"]["line"], json["source_location"]["column"],) ) end CacheContents.new( file_contents_digest: cache_contents_json["file_contents_digest"], unresolved_references: unresolved_references, ) end end sig { returns(String) } def serialize to_json end end CacheShape = T.type_alias do T::Hash[ String, CacheContents ] end sig { params(enable_cache: T::Boolean, cache_directory: Pathname, config_path: T.nilable(String)).void } def initialize(enable_cache:, cache_directory:, config_path:) @enable_cache = enable_cache @cache = T.let({}, CacheShape) @files_by_digest = T.let({}, T::Hash[String, String]) @config_path = config_path @cache_directory = cache_directory if @enable_cache create_cache_directory! bust_cache_if_packwerk_yml_has_changed! bust_cache_if_inflections_have_changed! end end sig { void } def bust_cache! FileUtils.rm_rf(@cache_directory) end sig do params( file_path: String, block: T.proc.returns(T::Array[UnresolvedReference]) ).returns(T::Array[UnresolvedReference]) end def with_cache(file_path, &block) return yield unless @enable_cache cache_location = @cache_directory.join(digest_for_string(file_path)) cache_contents = if cache_location.exist? T.let(CacheContents.deserialize(cache_location.read), CacheContents) end file_contents_digest = digest_for_file(file_path) if !cache_contents.nil? && cache_contents.file_contents_digest == file_contents_digest Debug.out("Cache hit for #{file_path}") cache_contents.unresolved_references else Debug.out("Cache miss for #{file_path}") unresolved_references = yield cache_contents = CacheContents.new( file_contents_digest: file_contents_digest, unresolved_references: unresolved_references, ) cache_location.write(cache_contents.serialize) unresolved_references end end sig { params(file: String).returns(String) } def digest_for_file(file) digest_for_string(File.read(file)) end sig { params(str: String).returns(String) } def digest_for_string(str) # MD5 appears to be the fastest # https://gist.github.com/morimori/1330095 Digest::MD5.hexdigest(str) end sig { void } def bust_cache_if_packwerk_yml_has_changed! return nil if @config_path.nil? bust_cache_if_contents_have_changed(File.read(@config_path), :packwerk_yml) end sig { void } def bust_cache_if_inflections_have_changed! bust_cache_if_contents_have_changed(YAML.dump(ActiveSupport::Inflector.inflections), :inflections) end sig { params(contents: String, contents_key: Symbol).void } def bust_cache_if_contents_have_changed(contents, contents_key) current_digest = digest_for_string(contents) cached_digest_path = @cache_directory.join(contents_key.to_s) if !cached_digest_path.exist? # In this case, we have nothing cached # We save the current digest. This way the next time we compare current digest to cached digest, # we can accurately determine if we should bust the cache cached_digest_path.write(current_digest) nil elsif cached_digest_path.read == current_digest Debug.out("#{contents_key} contents have NOT changed, preserving cache") else Debug.out("#{contents_key} contents have changed, busting cache") bust_cache! create_cache_directory! cached_digest_path.write(current_digest) end end sig { void } def create_cache_directory! FileUtils.mkdir_p(@cache_directory) end end class Debug class << self extend T::Sig sig { params(out: String).void } def out(out) if ENV["DEBUG_PACKWERK_CACHE"] puts(out) end end end end private_constant :Cache private_constant :Debug end