This commit is contained in:
Loic Nageleisen 2014-12-10 18:33:15 +01:00
commit 32b99b62bf
20 changed files with 644 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/Gemfile.lock
/.yardoc
/doc
/coverage
*.gem

2
.rspec Normal file
View file

@ -0,0 +1,2 @@
--color
--require spec_helper

3
.rubocop.yml Normal file
View file

@ -0,0 +1,3 @@
TrivialAccessors:
ExactNameMatch: true
AllowPredicates: true

7
.travis.yml Normal file
View file

@ -0,0 +1,7 @@
language: ruby
ruby:
- 1.9.3
- 2.0.0
- 2.1.3
script:
rake spec rubocop

3
Gemfile Normal file
View file

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gemspec

19
LICENSE Normal file
View file

@ -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.

211
README.md Normal file
View file

@ -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 #=> #<Package 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 #=> #<Package foo>
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 #=> #<Package 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 # #<Package foo/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"=>#<Package foo>, ...}
# 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: <RubyVM::InstructionSequence:block in __pry__@(pry)>=========
== 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

21
Rakefile Normal file
View file

@ -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

151
lib/package.rb Normal file
View file

@ -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

1
lib/pak.rb Normal file
View file

@ -0,0 +1 @@
require_relative 'package'

20
pak.gemspec Normal file
View file

@ -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

0
spec/fixtures/empty_package.rb vendored Normal file
View file

68
spec/package_spec.rb Normal file
View file

@ -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

26
spec/spec_helper.rb Normal file
View file

@ -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

5
spec/support/matchers.rb Normal file
View file

@ -0,0 +1,5 @@
RSpec::Matchers.define :have_constant do |const|
match do |owner|
owner.const_defined?(const)
end
end

47
test.rb Normal file
View file

@ -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

7
test/bar.rb Normal file
View file

@ -0,0 +1,7 @@
import 'foo'
HELLO = 'BAR'
def hello
"i am bar and I can access #{foo.name}"
end

5
test/foo.rb Normal file
View file

@ -0,0 +1,5 @@
HELLO = 'FOO'
def hello
'i am foo'
end

5
test/foo/baz.rb Normal file
View file

@ -0,0 +1,5 @@
HELLO = 'BAZ'
def hello
'i am baz'
end

38
test2.rb Normal file
View file

@ -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')