commit 32b99b62bfa19e72f211787d57e7e0455f4ae1e0 Author: Loic Nageleisen Date: Wed Dec 10 18:33:15 2014 +0100 preview diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa0dd55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/Gemfile.lock +/.yardoc +/doc +/coverage +*.gem diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..83e16f8 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..96c6c0b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,3 @@ +TrivialAccessors: + ExactNameMatch: true + AllowPredicates: true diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f47fefd --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: ruby +ruby: + - 1.9.3 + - 2.0.0 + - 2.1.3 +script: + rake spec rubocop diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c6d0ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 Loic Nageleisen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d4029a --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# Pak - A namespaced package system for Ruby + +## Why, oh why? + +If you are: + +- sick of side effects when requiring? +- think writing namespaces when you're already nested deep in directories is + not quite DRY? +- want easier reloading/dependency tracking/source mapping? + +You've come to the right place. + +## A quick note about require, monkeys, and use cases + +I have no issue *per se* with monkey-patching, require being side-effectful WRT +namespacing by design, and classes (modules, really) being open. The trouble +is, most of the time it's not what we need, and more often than not it gets in +the way in terrible ways. Hence, this, taking inspiration from Python and Go. + +## An example, for good measure + +Take a module named `foo.rb`: + +````ruby +HELLO = 'FOO' + +def hello + 'i am foo' +end +``` + +Importing `foo` will make it accessible to `self`: + +```ruby +import('foo', as: :method) +p foo #=> # +p foo.name #=> "foo" +p foo.hello #=> "i am foo" +p foo::HELLO #=> "FOO" +``` + +To avoid pollution (especially with `main` being a special case of `Object`), +import defines a `.foo` method on the caller's `class << self`, so that `foo` +may not become accessible from too much unexpected places. + +```ruby +class ABC; end +p ABC.new.foo # NoMethodError +``` + +You can import under another name to prevent conflict: + +```ruby +import('foo', as: :fee) +p fee #=> # +p fee.name #=> "foo" +p fee.hello #=> "i am foo" +p fee::HELLO #=> "FOO" +``` + +Alternatively, you can import as a const: + +```ruby +import('foo', to: :const) +p Foo #=> # +p Foo.name #=> "foo" +p Foo.hello #=> "i am foo" +p Foo::HELLO #=> "FOO" +``` + +And if that doesn't suit you, you can import as a local: + +```ruby +qux = import('foo', to: nil) +p qux +puts qux.name +puts qux.hello +puts qux::HELLO +``` + +From a package `bar.rb`, you can import `foo`...: + +```ruby +import 'foo' + +HELLO = 'BAR' + +def hello + "i am bar and I can access #{foo.name}" +end +``` + +...all without polluting anyone: + +```ruby +import('bar') +p bar +p bar.name +p bar.hello +p foo # NameError +``` + +Note that `foo` as used by `bar` is visible to the world: + +```ruby +p bar.foo +p bar.foo.name +``` + +Packages can be nested. Here's a surprising `foo/baz.rb` file: + +```ruby +HELLO = 'BAZ' + +def hello + 'i am baz' +end +``` + +You can guess how to use it: + +```ruby +import('foo/baz') +p baz # # +p baz.name +p baz.hello +p foo # NameError +``` + +Importing a package will load the package only once, as future import calls +will reuse the cached version. Loaded packages can be listed and manipulated, +allowing a reload for future instances. + +```ruby +foo.object_id #=> 70151878063900 +p Package.loaded #=> {"foo.rb"=>#, ...} +# old_foo = Package.delete("foo") +old_foo = Package.loaded.delete("foo.rb") +import 'foo' +foo.object_id #=> 70151879713940 +``` + +`foo` in `bar` will be reloaded once bar itself is reloaded. The logic is that +while you *may* want new code to be reloaded by old code sometimes, you'd +rather not have old code call new code in an incompatible manner. So, to +minimize surprise, global (i.e unscoped const) reload is declared a bad thing +and module scoped reload is favored. + +```ruby +bar.foo.object_id == old_foo.object_id #=> true +``` + +Dependency tracking becomes easy, and reloading a whole graph just as well: + +```ruby +bar.dependencies #=> 'foo' +bar = bar.reload! # evicts dependencies recursively and reimports bar + +## Wishlist: setting locals directly + +I hoped to be able to have an implicit syntax similar to Python or Go allowing +for real local variable setting, but this seems unlikely given how local +variables are set up by the Ruby VM: although you can get the +`binding.of_caller`, modifying the binding doesn't *create* the variable as a +caller's local. As such, you can guess how being forced to do `foo = nil; +import 'foo'` is not really useful (and entirely arcane) when compared to `foo += import 'foo'`. + +See how [`bind_local_variable_set`][1] works on a binding, defining new vars +dynamically inside the binding but outside the local table, resulting in the +following behavior (excerpted form Ruby's own inline doc): + +```ruby +def foo + a = 1 + b = binding + b.local_variable_set(:a, 2) # set existing local variable `a' + b.local_variable_set(:b, 3) # create new local variable `b' + # `b' exists only in binding. + b.local_variable_get(:a) #=> 2 + b.local_variable_get(:b) #=> 3 + p a #=> 2 + p b #=> NameError +end +``` + +A good way to look at the local table is to use RubyVM ISeq features: + + > puts RubyVM::InstructionSequence.disasm(-> { foo=42 }) + == disasm: ========= + == catch table + | catch type: redo st: 0002 ed: 0009 sp: 0000 cont: 0002 + | catch type: next st: 0002 ed: 0009 sp: 0000 cont: 0009 + |------------------------------------------------------------------------ + local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1) + [ 2] foo + 0000 trace 256 ( 13) + 0002 trace 1 + 0004 putobject 42 + 0006 dup + 0007 setlocal_OP__WC__0 2 + 0009 trace 512 + 0011 leave + => nil + +That's because, IIUC, the local variables table is basically fixed and cannot +be changed, so the binding works around that with dynavars, but it doesn't +bubble up to the function local table. + +[1]: https://github.com/ruby/ruby/blob/6b6ba319ea4a5afe445bad918a214b7d5691fd7c/proc.c#L473 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..dd6852f --- /dev/null +++ b/Rakefile @@ -0,0 +1,21 @@ +require 'yard' +YARD::Rake::YardocTask.new do |t| + t.files = ['lib/**/*.rb'] + t.options = %w(- README.md LICENSE CONTRIBUTING) +end + +require 'rspec/core' +require 'rspec/core/rake_task' +desc 'Run all specs in spec directory (excluding plugin specs)' +RSpec::Core::RakeTask.new + +require 'rubocop/rake_task' +RuboCop::RakeTask.new + +desc 'Run RSpec with code coverage' +task :coverage do + ENV['COVERAGE'] = 'yes' + Rake::Task['spec'].execute +end + +task default: :spec diff --git a/lib/package.rb b/lib/package.rb new file mode 100644 index 0000000..c81a6b7 --- /dev/null +++ b/lib/package.rb @@ -0,0 +1,151 @@ +require 'binding_of_caller' + +# +class Package < Module + # + module KernelMethods + NS_SEP = '/' + + module_function + + # Return the package as a value + def import(caller_binding, namespace, as: nil, to: :method) + to ||= :value + + send("import_to_#{to}", caller_binding, namespace, as: as) + end + + # Return the package as a value + def import_to_value(_binding, namespace, as: nil) + Package.new(namespace) + end + + # Assign the package to a local variable in the caller's binding + # Return the package as a value + # /!\ Experimental + def import_to_local(caller_binding, namespace, as: nil) + sym = (as || ns_last(namespace)).to_sym + setter = caller_binding.eval(<<-RUBY) + #{sym} = nil + -> (v) { #{sym} = v } + RUBY + + setter.call(Package.new(namespace)) + end + + # Define a method in the caller's context that hands out the package + # Return the package as a value + def import_to_method(caller_binding, namespace, as: nil) + sym = (as || ns_last(namespace)).to_sym + clr = caller_binding.eval('self') + setter = clr.instance_eval(<<-RUBY) + -> (v) { define_singleton_method(:#{sym}) { v }; v } + RUBY + + setter.call(Package.new(namespace)) + end + + # Set a const to the package in the caller's context + # Return the package as a value + def import_to_const(caller_binding, namespace, as: nil) + sym = (as || ns_classify(ns_last(namespace)).to_sym) + clr = caller_binding.eval('self') + target = clr.respond_to?(:const_set) ? clr : clr.class + setter = target.instance_eval(<<-RUBY) + -> (v) { const_set(:#{sym}, v) } + RUBY + + setter.call(Package.new(namespace)) + end + + def ns_from_filename(ns) + ns.gsub('/', NS_SEP).gsub(/\.rb$/, '') + end + + def ns_to_filename(ns) + ns.gsub(NS_SEP, '/') + '.rb' + end + + def ns_last(ns) + ns.split(NS_SEP).last + end + + def ns_classify(namespace) + namespace.split(NS_SEP).map! do |v| + v.split('_').map!(&:capitalize).join('') + end.join('::') + end + end + + class << self + def new(file) + file = KernelMethods.ns_to_filename(file) + self[file] ||= super(file) + end + + def [](key) + loaded[key] + end + + def []=(key, value) + loaded[key] = value + end + + def loaded + @store ||= {} + end + + def delete(ns) + @store.delete(ns_to_filename(ns)) + end + + def reload! + @store = {} + end + + def path + $LOAD_PATH + end + end + + def initialize(file) + @source_file = file + @name = KernelMethods.ns_from_filename(file) + load_in_module(file) + end + + attr_reader :name + alias_method :to_s, :name + + def load(file, wrap = false) + wrap ? super : load_in_module(File.join(dir, file)) + rescue Errno::ENOENT + super + end + + def inspect + format("#<#{self.class.name}(#{name}):0x%014x>", object_id) + end + + private + + def load_in_module(file) + module_eval(IO.read(file), + File.expand_path(file)) + rescue Errno::ENOENT + raise + end + + def method_added(name) + module_function(name) + end +end + +# +module Kernel + def import(*args) + caller_binding = args.last.is_a?(Binding) ? args.pop : binding.of_caller(1) + args.unshift(caller_binding) + Package::KernelMethods.import(*args) + end +end diff --git a/lib/pak.rb b/lib/pak.rb new file mode 100644 index 0000000..de39da1 --- /dev/null +++ b/lib/pak.rb @@ -0,0 +1 @@ +require_relative 'package' diff --git a/pak.gemspec b/pak.gemspec new file mode 100644 index 0000000..f48ba7c --- /dev/null +++ b/pak.gemspec @@ -0,0 +1,20 @@ +Gem::Specification.new do |s| + s.name = 'pak' + s.version = '1.0.0' + s.licenses = ['MIT'] + s.summary = 'Packaged namespacing for Ruby' + s.description = 'Implicit namespacing and package definition, '\ + 'inspired by Python, Go, CommonJS.' + s.authors = ['Loic Nageleisen'] + s.email = 'loic.nageleisen@gmail.com' + s.files = Dir['lib/*'] + + s.add_development_dependency 'rake', '~> 10.3' + s.add_development_dependency 'rspec', '~> 3.0' + s.add_development_dependency 'simplecov' + s.add_development_dependency 'rubocop' + s.add_development_dependency 'yard', '~> 0.8.7' + s.add_development_dependency 'binding_of_caller' + + s.add_development_dependency 'pry' +end diff --git a/spec/fixtures/empty_package.rb b/spec/fixtures/empty_package.rb new file mode 100644 index 0000000..e69de29 diff --git a/spec/package_spec.rb b/spec/package_spec.rb new file mode 100644 index 0000000..38df126 --- /dev/null +++ b/spec/package_spec.rb @@ -0,0 +1,68 @@ +require 'package' + +RSpec.describe Package do + context 'instance' do + let(:package) { Package.new('spec/fixtures/empty_package') } + + it { expect(package).to be_a Module } + end +end + +RSpec.describe 'Kernel#import' do + before(:each) { Package.reload! } + + [Module, Object].each do |ctx| + let(:package_name) { 'spec/fixtures/empty_package' } + let(:target) { nil } + let(:pak) do + t = target + n = package_name + context.instance_eval { import(n, to: t) } + end + + context "called inside #{ctx.name}.new" do + let(:context) { ctx.new } + + it 'should import a package as a value' do + expect(pak).to be_a Package + expect(context).not_to respond_to :empty_package + expect do + context.instance_eval { EmptyPackage } + end.to raise_error NameError + expect do + context.instance_eval { empty_package } + end.to raise_error NameError + end + + it 'should import a package as a method' do + pak = nil + context = Module.new do + pak = import('spec/fixtures/empty_package', to: :method) + end + + expect(context).to respond_to :empty_package + expect(context.empty_package).to eq pak + end + + it 'should import a package as a const' do + pak = nil + context = Module.new do + pak = import('spec/fixtures/empty_package', to: :const) + end + + expect(context).to have_constant :EmptyPackage + expect(context::EmptyPackage).to eq pak + end + + it 'should import a package as a local' do + pending 'not ready yet' + + context = Module.new do + import('spec/fixtures/empty_package', to: :local) + end + + expect(context.instance_eval { empty_package }).to be_a Package + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f104df8 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,26 @@ +if ENV['COVERAGE'] == 'yes' + require 'simplecov' + SimpleCov.start +end + +require 'support/matchers' +require 'pry' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.disable_monkey_patching! + config.warnings = true + + config.default_formatter = 'doc' if config.files_to_run.one? + + config.order = :random + + Kernel.srand config.seed +end diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb new file mode 100644 index 0000000..5a671af --- /dev/null +++ b/spec/support/matchers.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :have_constant do |const| + match do |owner| + owner.const_defined?(const) + end +end diff --git a/test.rb b/test.rb new file mode 100644 index 0000000..7145af8 --- /dev/null +++ b/test.rb @@ -0,0 +1,47 @@ +$LOAD_PATH.push File.expand_path(File.join(File.dirname(__FILE__), 'lib')) + +require 'pry' +require 'binding_of_caller' +require 'package' + + +if __FILE__ == $PROGRAM_NAME + Dir.chdir('test') + + import('foo') + p foo + puts foo.name + puts foo.hello + puts foo::HELLO + + import('foo', as: :fee) + p fee + puts fee.name + puts fee.hello + puts fee::HELLO + + import('foo', to: :const) + p Foo + puts Foo.name + puts Foo.hello + puts Foo::HELLO + + import('bar') + p bar + p bar.name + p bar.hello + p bar.foo + p bar.foo.name + + import('foo/baz', as: 'baz2') + p baz2.name + + import('foo/baz') + p baz.name + + p Package.loaded +end + +class ABC; end + +p ABC.new.foo diff --git a/test/bar.rb b/test/bar.rb new file mode 100644 index 0000000..16a0ca9 --- /dev/null +++ b/test/bar.rb @@ -0,0 +1,7 @@ +import 'foo' + +HELLO = 'BAR' + +def hello + "i am bar and I can access #{foo.name}" +end diff --git a/test/foo.rb b/test/foo.rb new file mode 100644 index 0000000..f59d107 --- /dev/null +++ b/test/foo.rb @@ -0,0 +1,5 @@ +HELLO = 'FOO' + +def hello + 'i am foo' +end diff --git a/test/foo/baz.rb b/test/foo/baz.rb new file mode 100644 index 0000000..4432478 --- /dev/null +++ b/test/foo/baz.rb @@ -0,0 +1,5 @@ +HELLO = 'BAZ' + +def hello + 'i am baz' +end diff --git a/test2.rb b/test2.rb new file mode 100644 index 0000000..4fe96ce --- /dev/null +++ b/test2.rb @@ -0,0 +1,38 @@ +require 'pp' +#require 'pry' +require 'binding_of_caller' + +def cards + ace = 'of hearts' + queen = 'of diamonds' + binding +end + +c = cards + +pp c.eval('ace') +pp c.eval('ace = "of spades"') +pp c.eval('ace') +pp c.eval('foo = 42') +pp c.eval('foo') + +def set_baz(b) + b.eval('baz = 44') + pp b.eval('baz') + pp binding.callers +end + +def meh + foo = 42 + bar = 43 + #baz = nil + + set_baz binding + binding +end + +m = meh + +pp m.eval('foo') +pp m.eval('bar') +pp m.eval('baz')