From 32b99b62bfa19e72f211787d57e7e0455f4ae1e0 Mon Sep 17 00:00:00 2001 From: Loic Nageleisen Date: Wed, 10 Dec 2014 18:33:15 +0100 Subject: [PATCH] preview --- .gitignore | 5 + .rspec | 2 + .rubocop.yml | 3 + .travis.yml | 7 ++ Gemfile | 3 + LICENSE | 19 +++ README.md | 211 +++++++++++++++++++++++++++++++++ Rakefile | 21 ++++ lib/package.rb | 151 +++++++++++++++++++++++ lib/pak.rb | 1 + pak.gemspec | 20 ++++ spec/fixtures/empty_package.rb | 0 spec/package_spec.rb | 68 +++++++++++ spec/spec_helper.rb | 26 ++++ spec/support/matchers.rb | 5 + test.rb | 47 ++++++++ test/bar.rb | 7 ++ test/foo.rb | 5 + test/foo/baz.rb | 5 + test2.rb | 38 ++++++ 20 files changed, 644 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 lib/package.rb create mode 100644 lib/pak.rb create mode 100644 pak.gemspec create mode 100644 spec/fixtures/empty_package.rb create mode 100644 spec/package_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/matchers.rb create mode 100644 test.rb create mode 100644 test/bar.rb create mode 100644 test/foo.rb create mode 100644 test/foo/baz.rb create mode 100644 test2.rb 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')