diff --git a/Gemfile b/Gemfile index 19f54a8..647b7a1 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,8 @@ source "https://rubygems.org" gemspec +gem "listen" + group :development do gem "activesupport" gem "dry-cli" diff --git a/Gemfile.lock b/Gemfile.lock index 5df19c7..65ca391 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,15 +17,22 @@ GEM concurrent-ruby (1.1.9) diff-lcs (1.4.4) dry-cli (1.0.0) + ffi (1.15.5) i18n (1.8.11) concurrent-ruby (~> 1.0) jaro_winkler (1.5.4) + listen (3.8.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) minitest (5.14.4) parallel (1.19.2) parser (3.0.2.0) ast (~> 2.4.1) rainbow (3.0.0) rake (13.0.6) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) rexml (3.2.5) rspec (3.10.0) rspec-core (~> 3.10.0) @@ -66,6 +73,7 @@ DEPENDENCIES awesome_print (~> 1.9.2) dry-cli fun-ruby! + listen rake (~> 13.0.6) rspec (~> 3.10.0) rubocop (~> 0.81.0) diff --git a/lib/fun_ruby.rb b/lib/fun_ruby.rb index 78c3ed6..7a6fdcc 100644 --- a/lib/fun_ruby.rb +++ b/lib/fun_ruby.rb @@ -22,8 +22,48 @@ module FunRuby # end # end # F.container.fetch("functions.sum").(2, 3) # => 5 - def define(&block) - Container::Define.build(container: container).(&block) + def define(target = nil, &block) + target ||= container + file_path, _line_number = caller.first.split(":") + target.add_definition_path(file_path, true) + Container::Define.build(container: target).(&block) + end + + # Accepts a glob of files where the container is defined and add the definition paths to the container + def add_definition_paths(glob, to: nil) + target = to || container + Dir.glob(glob).each do |path| + target.add_definition_path(path, false) + end + end + + # Loads all the defined paths for the passed container + def load_definitions!(target = nil) + target ||= container + target.definition_paths.reject(&:loaded?).each do |definition| + load definition.path + end + end + + # Enables hot reloading + # TODO: Create a better implementation + # TODO: Cover by tests + # TODO: Write a better documentation + # TODO: Check if a container support overriding + def enable_hot_reloading!(target = nil) + target ||= container + + target.definition_paths.each do |definition| + definition_dir = begin + *dir_parts, _file = definition.path.split("/") + dir_parts.join("/") + end + + listener = Listen.to(definition_dir) do |modified| + load definition.path if modified.include?(definition.path) + end + listener.start + end end # Returns a global container @@ -35,6 +75,17 @@ def container @container ||= Container.new end + # Accepts a container that will be considered global + # + # @since 0.1.0 + # + # @return [FunRuby::Container] + def container=(container) + raise ArgumentError, "The global container is already defined" if instance_variable_defined?(:@container) + + @container = container + end + # Allows to import global container to your classes and modules # # @since 0.1.0 diff --git a/lib/fun_ruby/container.rb b/lib/fun_ruby/container.rb index 8ec43f9..7aef75a 100644 --- a/lib/fun_ruby/container.rb +++ b/lib/fun_ruby/container.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require_relative "container/mixin" +require_relative "container/config" +require_relative "container/definition_path" module FunRuby # @private @@ -8,17 +10,22 @@ class Container NAMESPACE_SEPARATOR = "." NOT_EVALUATED = Object.new.freeze + # Returns a list of files with definitions for the container + attr_reader :definition_paths + # @private - def initialize + def initialize(config = Config.new) @storage = {} @mutex = Mutex.new + @config = config + @definition_paths = Set.new end # @private def define(key, &block) key = key.to_s - raise KeyError, "#{key.inspect} is already defined" if storage.key?(key) + raise KeyError, "#{key.inspect} is already defined" if storage.key?(key) && config.cant_override? raise TypeError, "block should be given" unless block_given? storage[key] = init_meta(block) @@ -41,9 +48,16 @@ def import(*aliases) Mixin.build(aliases: aliases) end + # Adds a path to the definition path lit + def add_definition_path(path, loaded = false) + path = DefinitionPath.new(path: path, loaded: loaded) + # TODO: Add file existence validations and etc. + @definition_paths.add(path) + end + private - attr_reader :storage, :mutex + attr_reader :storage, :mutex, :config def init_meta(definition) { diff --git a/lib/fun_ruby/container/config.rb b/lib/fun_ruby/container/config.rb new file mode 100644 index 0000000..c5669e2 --- /dev/null +++ b/lib/fun_ruby/container/config.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module FunRuby + class Container + # The class store a configuration for a container + class Config + # @private + def initialize(override: false) + @override = override + end + + # @private + def can_override? + @override + end + + # @private + def cant_override? + !can_override? + end + + private + + attr_reader :override + end + end +end diff --git a/lib/fun_ruby/container/definition_path.rb b/lib/fun_ruby/container/definition_path.rb new file mode 100644 index 0000000..9052560 --- /dev/null +++ b/lib/fun_ruby/container/definition_path.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module FunRuby + class Container + # The class stores info about a container's definition path and its state + class DefinitionPath + # @private + attr_reader :path + # @private + def initialize(path:, loaded:) + @path = path + @loaded = loaded + end + + # @private + def eql?(other) + path == other.path + end + + # @private + def ==(other) + eql?(other) && loaded? == other.loaded? + end + + # @private + def hash + path.hash + end + + # @private + def loaded? + loaded + end + + private + + attr_reader :loaded + end + end +end diff --git a/spec/fixtures/container_definitions/definition.rb b/spec/fixtures/container_definitions/definition.rb new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/container_definitions/nested/definition.rb b/spec/fixtures/container_definitions/nested/definition.rb new file mode 100644 index 0000000..e69de29 diff --git a/spec/fun_ruby/container_spec.rb b/spec/fun_ruby/container_spec.rb index 59b8ddf..db1252d 100644 --- a/spec/fun_ruby/container_spec.rb +++ b/spec/fun_ruby/container_spec.rb @@ -22,6 +22,19 @@ expect { container.define(key) { function } }.to raise_error(KeyError) end + + it "does not raise an error when overriding is allowed by config", :aggregate_failures do + config = described_class::Config.new(override: true) + container = described_class.new(config) + first_definition = ->(x, y) { x + y } + key = "sum" + second_definition = ->(a, b) { a - b } + + container.define(key) { first_definition } + + expect { container.define(key) { second_definition } }.not_to raise_error + expect(container.fetch(key)).to equal(second_definition) + end end describe "#fetch" do @@ -42,4 +55,28 @@ expect { container.fetch(key) }.to raise_error(KeyError) end end + + describe "#add_definition_path" do + it "stores a new definition paths as not loaded yet" do + container = described_class.new + container.add_definition_path(__FILE__) + + definition_path = described_class::DefinitionPath.new( + path: __FILE__, + loaded: false + ) + expect(container.definition_paths.first).to eq(definition_path) + end + + it "allows storing definition paths as already loaded" do + container = described_class.new + container.add_definition_path(__FILE__, true) + + definition_path = described_class::DefinitionPath.new( + path: __FILE__, + loaded: true + ) + expect(container.definition_paths.first).to eq(definition_path) + end + end end diff --git a/spec/fun_ruby_spec.rb b/spec/fun_ruby_spec.rb new file mode 100644 index 0000000..906b1c5 --- /dev/null +++ b/spec/fun_ruby_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../lib/fun_ruby" + +describe FunRuby do + before do + described_class.remove_instance_variable(:@container) if described_class.instance_variable_defined?(:@container) + end + + describe ".container=" do + it "sets the passed container as global" do + container = described_class::Container.new + + described_class.container = container + + expect(described_class.container).to equal(container) + end + + it "does not allow settings a global container twice" do + container = described_class::Container.new + + described_class.container = container + + expect { described_class.container = container }.to raise_error(ArgumentError) + end + end + + describe ".define" do + it "defines for a global container where no args are passed" do + function = ->(x) { x } + + described_class.define do + namespace :hello do + f(:world) { function } + end + end + + expect(F.container.fetch("hello.world")).to equal(function) + end + + it "defines for a passed container", :aggregate_failures do + container = described_class::Container.new + function = ->(x) { x } + + described_class.define(container) do + namespace :hello do + f(:world) { function } + end + end + + expect(container.fetch("hello.world")).to equal(function) + expect { F.container.fetch("hello.world") }.to raise_error(KeyError) + end + + it "saves a definition path of the file where it's executed as already loaded" do + container = described_class::Container.new + function = ->(x) { x } + + described_class.define(container) do + namespace :hello do + f(:world) { function } + end + end + + definition_path = described_class::Container::DefinitionPath.new( + path: __FILE__, + loaded: true + ) + + expect(container.definition_paths.first).to eq(definition_path) + end + end + + describe ".add_definition_paths" do + it "finds all the files matching the passed glob and stores them as not loaded definition paths" do + container = described_class::Container.new + glob = File.expand_path("./fixtures/container_definitions/**/*.rb", __dir__) + + described_class.add_definition_paths(glob, to: container) + + Dir.glob(glob).each do |file_path| + definition_path = container.definition_paths.find do |definition| + definition.path == file_path + end + + expect(definition_path).not_to be(nil) + expect(definition_path.loaded?).to be(false) + end + end + end +end