Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion nanoc-cli/lib/nanoc/cli/commands/show-data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,17 @@ def describe_identifiable_collection_dependency(dep)
outcome << 'matching any attribute'
when Set
dep.props.attributes.each do |elem|
outcome << "matching attribute #{elem.inspect}"
case elem
when Symbol
outcome << "matching attribute #{elem.inspect} (any value)"
when Array
outcome << "matching attribute pair #{elem[0].inspect} => #{elem[1].inspect}"
else
raise(
Nanoc::Core::Errors::InternalInconsistency,
"unexpected prop attribute element #{elem.inspect}",
)
end
end
else
raise(
Expand Down
40 changes: 38 additions & 2 deletions nanoc-core/lib/nanoc/core/dependency_props.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ class DependencyProps

C_ATTR =
C::Or[
C::SetOf[Symbol],
C::ArrayOf[Symbol],
C::SetOf[
C::Or[
Symbol, # any value
[Symbol, C::Any] # pair (specific value)
],
],
C::ArrayOf[
C::Or[
Symbol, # any value
[Symbol, C::Any] # pair (specific value)
],
],
C::Bool
]

Expand Down Expand Up @@ -66,6 +76,23 @@ def inspect
s << (attributes? ? 'a' : '_')
s << (compiled_content? ? 'c' : '_')
s << (path? ? 'p' : '_')

if @raw_content.is_a?(Set)
@raw_content.each do |elem|
s << '; raw_content('
s << elem.inspect
s << ')'
end
end

if @attributes.is_a?(Set)
@attributes.each do |elem|
s << '; attr('
s << elem.inspect
s << ')'
end
end

s << ')'
end
end
Expand Down Expand Up @@ -156,6 +183,15 @@ def active
end
end

def attribute_keys
case @attributes
when Enumerable
@attributes.map { |a| a.is_a?(Array) ? a.first : a }
else
[]
end
end

contract C::None => Hash
def to_h
{
Expand Down
6 changes: 6 additions & 0 deletions nanoc-core/lib/nanoc/core/dependency_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class DependencyStore < ::Nanoc::Core::Store
C_ATTR =
C::Or[
C::ArrayOf[Symbol],
C::HashOf[Symbol => C::Any],
C::Bool
]

Expand Down Expand Up @@ -140,6 +141,11 @@ def record_dependency(src, dst, raw_content: false, attributes: false, compiled_
src_ref = obj2ref(src)
dst_ref = obj2ref(dst)

# Convert attributes into key-value pairs, if necessary
if attributes.is_a?(Hash)
attributes = attributes.to_a
end

existing_props = @graph.props_for(dst_ref, src_ref)
new_props = Nanoc::Core::DependencyProps.new(raw_content: raw_content, attributes: attributes, compiled_content: compiled_content, path: path)
props = existing_props ? existing_props.merge(new_props) : new_props
Expand Down
1 change: 1 addition & 0 deletions nanoc-core/lib/nanoc/core/dependency_tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class DependencyTracker
C_ATTR =
C::Or[
C::ArrayOf[Symbol],
C::HashOf[Symbol => C::Any],
C::Bool
]

Expand Down
2 changes: 2 additions & 0 deletions nanoc-core/lib/nanoc/core/feature.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ def self.all_outdated
end
end
end

Nanoc::Core::Feature.define('where', version: '4.12')
28 changes: 28 additions & 0 deletions nanoc-core/lib/nanoc/core/identifiable_collection_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,34 @@ def find_all(arg = NOTHING, &block)
@objects.find_all(arg).map { |i| view_class.new(i, @context) }
end

# Finds all objects that have the given attribute key/value pair.
#
# @example
#
# @items.where(kind: 'article')
# @items.where(kind: 'article', year: 2020)
#
# @return [Enumerable]
def where(**hash)
unless Nanoc::Core::Feature.enabled?(Nanoc::Core::Feature::WHERE)
raise(
Nanoc::Core::TrivialError,
'#where is experimental, and not yet available unless the corresponding feature flag is turned on. Set the `NANOC_FEATURES` environment variable to `where` to enable its usage. (Alternatively, set the environment variable to `all` to turn on all feature flags.)',
)
end

@context.dependency_tracker.bounce(_unwrap, attributes: hash)

# IDEA: Nanoc could remember (from the previous compilation) how many
# times #where is called with a given attribute key, and memoize the
# key-to-identifiers list.
found_objects = @objects.select do |i|
hash.all? { |k, v| i.attributes[k] == v }
end

found_objects.map { |i| view_class.new(i, @context) }
end

# @overload [](string)
#
# Finds the object whose identifier matches the given string.
Expand Down
119 changes: 88 additions & 31 deletions nanoc-core/lib/nanoc/core/outdatedness_checker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,37 +187,9 @@ def dependency_causes_outdatedness?(dependency)
true
when Nanoc::Core::ItemCollection, Nanoc::Core::LayoutCollection
all_objects = dependency.from
matching_objects =
case dependency.props.raw_content
when true
# If the `raw_content` dependency prop is `true`, then this is a
# dependency on all *objects* (items or layouts).
all_objects
when Enumerable
# If the `raw_content` dependency prop is a collection, then this
# is a dependency on specific objects, given by the patterns.
patterns = dependency.props.raw_content.map { |r| Nanoc::Core::Pattern.from(r) }
patterns.flat_map { |pat| all_objects.select { |obj| pat.match?(obj.identifier) } }
else
raise(
Nanoc::Core::Errors::InternalInconsistency,
"Unexpected type of raw_content: #{dependency.props.raw_content.inspect}",
)
end

# For all objects matching the `raw_content` dependency prop:
# If the object is outdated because it is newly added,
# then this dependency causes outdatedness.
#
# Note that these objects might be modified but *not* newly added,
# in which case this dependency will *not* cause outdatedness.
# However, when the object is used later (e.g. attributes are
# accessed), then another dependency will exist that will cause
# outdatedness.
matching_objects.any? do |obj|
status = basic.outdatedness_status_for(obj)
status.reasons.any? { |r| Nanoc::Core::OutdatednessReasons::DocumentAdded == r }
end
raw_content_prop_causes_outdatedness?(all_objects, dependency.props.raw_content) ||
attributes_prop_causes_outdatedness?(all_objects, dependency.props.attributes)
else
status = basic.outdatedness_status_for(dependency.from)

Expand All @@ -230,7 +202,92 @@ def dependency_causes_outdatedness?(dependency)

def attributes_unaffected?(status, dependency)
reason = status.reasons.find { |r| r.is_a?(Nanoc::Core::OutdatednessReasons::AttributesModified) }
reason && dependency.props.attributes.is_a?(Enumerable) && (dependency.props.attributes & reason.attributes).empty?
reason && dependency.props.attribute_keys.any? && (dependency.props.attribute_keys & reason.attributes).empty?
end

def raw_content_prop_causes_outdatedness?(objects, raw_content_prop)
return false unless raw_content_prop

matching_objects =
case raw_content_prop
when true
# If the `raw_content` dependency prop is `true`, then this is a
# dependency on all *objects* (items or layouts).
objects
when Enumerable
# If the `raw_content` dependency prop is a collection, then this
# is a dependency on specific objects, given by the patterns.
patterns = raw_content_prop.map { |r| Nanoc::Core::Pattern.from(r) }
patterns.flat_map { |pat| objects.select { |obj| pat.match?(obj.identifier) } }
else
raise(
Nanoc::Core::Errors::InternalInconsistency,
"Unexpected type of raw_content: #{raw_content_prop.inspect}",
)
end

# For all objects matching the `raw_content` dependency prop:
# If the object is outdated because it is newly added,
# then this dependency causes outdatedness.
#
# Note that these objects might be modified but *not* newly added,
# in which case this dependency will *not* cause outdatedness.
# However, when the object is used later (e.g. attributes are
# accessed), then another dependency will exist that will cause
# outdatedness.
matching_objects.any? do |obj|
status = basic.outdatedness_status_for(obj)
status.reasons.any? { |r| Nanoc::Core::OutdatednessReasons::DocumentAdded == r }
end
end

def attributes_prop_causes_outdatedness?(objects, attributes_prop)
return false unless attributes_prop

unless attributes_prop.is_a?(Set)
raise(
Nanoc::Core::Errors::InternalInconsistency,
'expected attributes_prop to be a Set',
)
end

pairs = attributes_prop.select { |a| a.is_a?(Array) }.to_h

unless pairs.any?
raise(
Nanoc::Core::Errors::InternalInconsistency,
'expected attributes_prop not to be empty',
)
end

dep_checksums = pairs.transform_values { |value| Nanoc::Core::Checksummer.calc(value) }

objects.any? do |object|
# Find old and new attribute checksums for the object
old_object_checksums = checksum_store.attributes_checksum_for(object)
next false unless old_object_checksums

new_object_checksums = checksums.attributes_checksum_for(object)

# Ignore any attribute not mentioned in the dependency
old_object_checksums = old_object_checksums.select { |k, _v| dep_checksums.key?(k) }
new_object_checksums = new_object_checksums.select { |k, _v| dep_checksums.key?(k) }

dep_checksums.any? do |key, dep_value|
# Get old and new checksum for this particular attribute
old_value = old_object_checksums[key]
new_value = new_object_checksums[key]

# If either the old or new vale match the value in the dependency,
# then a potential change is relevant to us, and can cause
# outdatedness.
is_match = [old_value, new_value].include?(dep_value)

is_changed = old_value != new_value

is_match && is_changed
end
end
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions nanoc-core/lib/nanoc/core/outdatedness_status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def update(reason)
props: @props.merge(reason.props),
)
end

def inspect
"<#{self.class} reasons=#{@reasons.inspect} props=#{@props.inspect}>"
end
end
end
end
50 changes: 50 additions & 0 deletions nanoc-core/spec/nanoc/core/outdatedness_checker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,56 @@

it { is_expected.to be(false) }
end

context 'dependency on specific new items (attribute)' do
before do
dependency_tracker = Nanoc::Core::DependencyTracker.new(dependency_store)
dependency_tracker.enter(item)
dependency_tracker.bounce(items, attributes: { kind: 'note' })
dependency_store.store
end

context 'nothing changed' do
it { is_expected.to be(false) }
end

context 'matching item added' do
let(:new_item) { Nanoc::Core::Item.new('stuff', { kind: 'note' }, '/new-note.md') }
let(:new_item_rep) { Nanoc::Core::ItemRep.new(new_item, :default) }

let(:action_sequences) do
super().merge({ new_item_rep => old_action_sequence_for_item_rep })
end

before do
reps << new_item_rep

dependency_store.items = Nanoc::Core::ItemCollection.new(config, items.to_a + [new_item])
dependency_store.load
end

it { is_expected.to be(true) }
end

context 'non-matching item added' do
before do
new_item = Nanoc::Core::Item.new('stuff', { kind: 'article' }, '/new-article.md')
dependency_store.items = Nanoc::Core::ItemCollection.new(config, items.to_a + [new_item])
dependency_store.load
end

it { is_expected.to be(false) }
end

context 'item removed' do
before do
dependency_store.items = Nanoc::Core::ItemCollection.new(config, [])
dependency_store.load
end

it { is_expected.to be(false) }
end
end
end
end

Expand Down
Loading