├── .gitignore ├── Gemfile ├── README.md ├── Rakefile ├── bin ├── ruby-prolog-acls └── ruby-prolog-hanoi ├── lib ├── ruby-prolog.rb └── ruby-prolog │ ├── ruby-prolog.rb │ └── version.rb ├── ruby-prolog.gemspec └── test ├── lib └── ruby-prolog │ └── ruby-prolog_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | **/.DS_Store -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '3.4.2' 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruby-prolog :: The Prolog-like DSL for Ruby 2 | 3 | 4 | ruby-prolog allows you to solve complex logic problems on the fly using a dynamic, Prolog-like DSL inline with your normal Ruby code. Basic use is encompassed by stating basic facts using your data, defining rules, and then asking questions. Why is this cool? Because ruby-prolog allows you to leave your normal object-oriented vortex on demand and step into the alternate reality of declarative languages. 5 | 6 | ruby-prolog has been used in projects ranging from complex realtime access control authorization in Rails apps, to headless 3D layout engines, and many other use cases. 7 | 8 | * Example: [Dynamic Access Control for IAM](bin/ruby-prolog-acls) 9 | * Example: [Towers of Hanoi solution](bin/ruby-prolog-hanoi) 10 | 11 | With ruby-prolog: 12 | 13 | * There are no classes. 14 | * There are no functions. 15 | * There are no variables. 16 | * There are no control flow statements. 17 | 18 | You *can* use all these wonder things -- it’s still Ruby after all -- but they’re not needed, and mainly useful for getting data and results into/out of the interpreter. Declarative langugaes like Prolog are often favored heavily in artificial intelligence and theorem proving applications and is also taught in computer science curricula, so I hope this updated release proves useful for your logic evaluation needs! 19 | 20 | ruby-prolog is written using object-oriented-ish pure Ruby, and should work under all modern Ruby interpreters. Please report compatibility problems. The core engine is largely based on tiny_prolog, though numerous additional enhancements have been made such as object-oriented refactorings and integration of ideas from the interwebs. Unfortunately I cannot read Japanese and cannot give proper attribution to the original tiny_prolog author. (If *you* can, let me know and I'll update this document!) 21 | 22 | Usage 23 | ---- 24 | 25 | Say you want to write the following Prolog code: 26 | 27 | ``` 28 | implication(a, b). 29 | implication(b, c). 30 | implication(c, d). 31 | implication(c, x). 32 | 33 | implies(A, B) :- implication(A, B). 34 | implies(A, B) :- implication(A, Something), implies(Something, B). 35 | ``` 36 | 37 | Here's the equivalent Ruby code using this library: 38 | 39 | ```rb 40 | db = RubyProlog.new do 41 | implication['a', 'b'].fact 42 | implication['b', 'c'].fact 43 | implication['c', 'd'].fact 44 | implication['c', 'x'].fact 45 | 46 | implies[:A, :B] << implication[:A, :B] 47 | implies[:A, :B] << [ 48 | implication[:A, :Something], 49 | implies[:Something, :B] 50 | ] 51 | end 52 | ``` 53 | 54 | Now you can run some queries: 55 | 56 | ```rb 57 | # What are all the direct implications of 'c'? 58 | db.query{ implication['c', :X] } 59 | #=> [{ X: 'd' }, { X: 'x' }] 60 | 61 | # What are all the things that can directly imply? 62 | db.query{ implication[:X, :_] } 63 | #=> [{ X: 'a' }, { X: 'b' }, { X: 'c' }, { X: 'c' }] 64 | 65 | # What are all the things 'a' implies? 66 | db.query{ implies['a', :X] } 67 | #=> [{ X: 'b' }, { X: 'c' }, { X: 'd' }, { X: 'x' }] 68 | ``` 69 | 70 | Unfortunately if you have **two** predicates in a query, you can't just use a comma. There two ways to solve this problem: 71 | 72 | ```rb 73 | # Solution 1: Use an array 74 | db.query{[ implication['b', :S], implies[:S, :B] ]} 75 | 76 | # Solution 2: Use a beneign assignment 77 | db.query{_= implication['b', :S], implies[:S, :B] } 78 | ``` 79 | 80 | If you need to add to your database, you can call `instance_eval`: 81 | 82 | ```rb 83 | db = RubyProlog.new do 84 | implication['a', 'b'].fact 85 | implication['b', 'c'].fact 86 | end 87 | 88 | # Later... 89 | db.instance_eval do 90 | implication['c', 'd'].fact 91 | implication['c', 'x'].fact 92 | end 93 | ``` 94 | 95 | This will mutate your database. If you want to "fork" your database instead, you can call `db.clone`, which will return a new instance with all stored data. Cloning like this is optimized to copy as little as possible. 96 | 97 | ### `to_prolog` 98 | 99 | If you're loading rules from a database, you might be generating predicates like this: 100 | 101 | ```rb 102 | rules = Ruleset.find_by(org: ...).rules 103 | db = RubyProlog.new do 104 | rules['permissions'].map do |role, perm| 105 | permission[role, perm].fact 106 | end 107 | end 108 | ``` 109 | 110 | However, if something doesn't work, how do find out why? 111 | 112 | This is where `#to_prolog` comes in handy. Just run it on your instance: 113 | 114 | ```rb 115 | puts db.to_prolog 116 | ``` 117 | 118 | and you'll get something that looks like this: 119 | 120 | ```text 121 | permission('admin', 'invite'). 122 | permission('admin', 'ban'). 123 | permission('membe', 'create_post'). 124 | ``` 125 | 126 | Then you can do a quick copy/paste into an environment like [Tau Prolog's sandbox](http://tau-prolog.org/sandbox/) or [SWISH](https://swish.swi-prolog.org) and run some queries. 127 | 128 | Examples 129 | ---- 130 | 131 | gem install ruby-prolog 132 | 133 | Two runnable examples are included in the 'bin' directory. The first.. 134 | 135 | ruby-prolog-acls 136 | 137 | ..shows the ruby-prolog dynamic DSL used to trivially implement access control checks. The second.. 138 | 139 | 140 | ruby-prolog-hanoi 141 | 142 | ..is a ruby-prolog solution to the well-known "Towers of Hanoi" problem in computer science. It's not clear, but something Prolog hackers will be interested in. If you have other useful or clever examples, please send a pull request! 143 | 144 | See the test/ directory for additional examples. 145 | 146 | Features 147 | ---- 148 | 149 | * Pure Ruby. 150 | * No wacko dependencies. 151 | * Tested with Ruby 2.0.0! 152 | * Object-oriented. 153 | * Multiple Prolog environments can be created and manipulated simultaneously. 154 | * Concurrent access to different core instances should be safe. 155 | * Concurrent access to a single core instance might probably explode in odd ways. 156 | 157 | Development 158 | ---- 159 | 160 | ``` 161 | $ git clone https://github.com/preston/ruby-prolog 162 | $ cd ruby-prolog 163 | $ bundle 164 | $ rake test 165 | ``` 166 | 167 | License 168 | ---- 169 | 170 | Released under the Apache 2.0 license. Copyright (c) 2013-2025 Preston Lee. All rights reserved. https://prestonlee.com 171 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'lib/ruby-prolog' 8 | t.test_files = FileList['test/lib/ruby-prolog/*_test.rb'] 9 | t.verbose = true 10 | end 11 | 12 | task :default => :test 13 | -------------------------------------------------------------------------------- /bin/ruby-prolog-acls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # By Preston Lee 4 | # 5 | 6 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 7 | require 'ruby-prolog' 8 | 9 | c = RubyProlog::Core.new 10 | c.instance_eval do 11 | 12 | # Let's put together an issue tracking system for Microsoft. 13 | 14 | # We'll start by declaring a few projects... 15 | project_status['me', 'live'].fact 16 | project_status['xp', 'live'].fact 17 | project_status['vista', 'live'].fact 18 | project_status['7', 'in_progress'].fact 19 | 20 | 21 | # Now we'll create a custom ACL system... 22 | role_can['user', 'create'].fact 23 | role_can['user', 'read'].fact 24 | 25 | role_can['qa', 'create'].fact 26 | role_can['qa', 'read'].fact 27 | role_can['qa', 'update'].fact 28 | 29 | role_can['admin', 'create'].fact 30 | role_can['admin', 'read'].fact 31 | role_can['admin', 'update'].fact 32 | role_can['admin', 'delete'].fact 33 | 34 | 35 | # Let's put people on different projects 36 | assigned['alice', 'me', 'user'].fact 37 | assigned['bob', 'me', 'qa'].fact 38 | assigned['charlie', 'me', 'qa'].fact 39 | 40 | assigned['alice', 'xp', 'user'].fact 41 | assigned['bob', 'xp', 'user'].fact 42 | assigned['charlie', 'xp', 'admin'].fact 43 | 44 | assigned['alice', 'vista', 'qa'].fact 45 | assigned['bob', 'vista', 'admin'].fact 46 | assigned['charlie', 'vista', 'admin'].fact 47 | 48 | assigned['alice', '7', 'user'].fact 49 | assigned['bob', '7', 'qa'].fact 50 | assigned['charlie', '7', 'qa'].fact 51 | assigned['dale', '7', 'admin'].fact 52 | 53 | 54 | # can_read_on_project[:U, :P] << [assigned[:U, :P, :R], role_can[:R, 'read']] 55 | can_on_project[:U, :X, :P] << [assigned[:U, :P, :R], role_can[:R, :X]] 56 | is_role_on_multiple_projects[:U, :R] << [ 57 | assigned[:U, :X, :R], 58 | assigned[:U, :Y, :R], 59 | noteq[:X, :Y]] 60 | # , noteq[:P1, :P2] 61 | 62 | puts 'Who does QA?' 63 | p query{assigned[:U, :P, 'qa']} 64 | 65 | puts "Who can access the 'vista' project?" 66 | p query{(can_on_project[:U, 'read', 'vista'])} 67 | 68 | puts "Does Alice have delete privileges on Vista?" 69 | puts query{can_on_project['alice', 'delete', 'vista']}.empty? ? "Yes" : "No" 70 | 71 | puts "Does Bob have delete privileges on Vista?" 72 | puts query{can_on_project['bob', 'delete', 'vista']}.empty? ? "Yes" : "No" 73 | 74 | puts "Who is an admin on multiple projects?" 75 | # puts query{is_role_on_multiple_projects[:U, 'admin']} 76 | 77 | require 'JSON' 78 | s = Array.new 79 | query{is_role_on_multiple_projects[:U, 'admin']}.each do |r| 80 | # puts r[:U].to_json 81 | s |= [r[:U]] # Put each result into the array, if not already present. 82 | end 83 | s.each do |n| puts n end # Print all unique results! 84 | 85 | end 86 | -------------------------------------------------------------------------------- /bin/ruby-prolog-hanoi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 4 | require 'ruby-prolog' 5 | 6 | # Inspired by.. 7 | # http://www.csupomona.edu/~jrfisher/www/prolog_tutorial/2_3.html 8 | # http://eigenclass.org/hiki.rb?tiny+prolog+in+ruby 9 | 10 | # Adapted by Preston Lee. 11 | 12 | c = RubyProlog::Core.new 13 | c.instance_eval do 14 | 15 | move[0,:X,:Y,:Z] << :CUT # There are no more moves left 16 | move[:N,:A,:B,:C] << [ 17 | is(:M,:N){|n| n - 1}, # reads as "M IS N - 1" 18 | move[:M,:A,:C,:B], 19 | write_info[:A,:B], 20 | move[:M,:C,:B,:A] 21 | ] 22 | write_info[:X,:Y] << [ 23 | write["move a disc from the "], 24 | write[:X], write[" pole to the "], 25 | write[:Y], writenl[" pole "] 26 | ] 27 | 28 | hanoi[:N] << move[:N,"left","right","center"] 29 | 30 | puts "\nWhat's the solution for a single disc?" 31 | query{hanoi[1]} 32 | 33 | puts "\n\nWhat's the solution for 5 discs?" 34 | query{hanoi[5]} 35 | 36 | # do_stuff[:STUFF].calls{|env| print env[:STUFF]; true} 37 | 38 | end 39 | -------------------------------------------------------------------------------- /lib/ruby-prolog.rb: -------------------------------------------------------------------------------- 1 | require_relative 'ruby-prolog/ruby-prolog.rb' 2 | 3 | module RubyProlog 4 | end 5 | -------------------------------------------------------------------------------- /lib/ruby-prolog/ruby-prolog.rb: -------------------------------------------------------------------------------- 1 | # Based on tiny_prolog h18.9/8 2 | # Fuglied by Preston Lee. 3 | module RubyProlog 4 | 5 | def self.new(&block) 6 | c = Core.new 7 | c.instance_eval(&block) if block_given? 8 | c 9 | end 10 | 11 | class Predicate 12 | @@id_counter = 0 13 | 14 | attr_reader :id, :name 15 | attr_accessor :db, :clauses 16 | 17 | def initialize(db, name, explicit_id: nil) 18 | @id = explicit_id || (@@id_counter += 1) 19 | @db = db 20 | @name = name 21 | @clauses = [] 22 | end 23 | 24 | def inspect 25 | @name.to_s 26 | end 27 | 28 | def [](*args) 29 | TempClause.new(@db, self, args) 30 | end 31 | 32 | def to_prolog 33 | @clauses.map do |head, body| 34 | "#{head.to_prolog}#{body ? " :- #{body.to_prolog}" : ''}." 35 | end.join("\n") 36 | end 37 | 38 | def fork(new_db) 39 | dupe = self.clone 40 | dupe.db = new_db 41 | dupe.clauses = dupe.clauses.dup 42 | dupe 43 | end 44 | 45 | # 46 | # DSL for 0-arity predicates 47 | # 48 | def <<(goals); TempClause.new(@db, self, []) << goals; end 49 | def to_goal; TempClause.new(@db, self, []).to_goal; end 50 | def fact; TempClause.new(@db, self, []).fact; end 51 | end 52 | 53 | class TempClause 54 | def initialize(db, pred, args) 55 | @db, @pred, @args = db, pred, args 56 | end 57 | 58 | def si(*rhs) 59 | goals = rhs.map do |x| 60 | case x 61 | when TempClause then x.to_goal 62 | when false then Goal.new(0, 'false', []) 63 | else x 64 | end 65 | end 66 | @db.append(self.to_goal, list(*goals)) 67 | end 68 | 69 | def fact 70 | si 71 | end 72 | 73 | def <<(rhs) 74 | case rhs 75 | when Array 76 | si(*rhs) 77 | else 78 | si(rhs) 79 | end 80 | end 81 | 82 | def calls(&callback) 83 | @db.append(self.to_goal, callback) 84 | end 85 | 86 | def to_goal 87 | Goal.new(@pred.id, @pred.name, @args.map do |arg| 88 | case arg 89 | when TempClause 90 | arg.to_goal 91 | else 92 | arg 93 | end 94 | end) 95 | end 96 | 97 | private 98 | 99 | def list(*x) 100 | y = nil 101 | x.reverse_each {|e| y = Cons.new(e, y)} 102 | return y 103 | end 104 | end 105 | 106 | class Goal 107 | 108 | attr_reader :pred_id, :pred_name, :args 109 | 110 | def initialize(pred_id, pred_name, args) 111 | @pred_id, @pred_name, @args = pred_id, pred_name, args 112 | end 113 | 114 | def inspect 115 | return @pred_name.to_s + @args.inspect.to_s 116 | end 117 | 118 | def to_prolog 119 | args_out = @args.map do |arg| 120 | case arg 121 | when Symbol 122 | if arg == :_ 123 | "_" 124 | elsif /[[:upper:]]/.match(arg.to_s[0]) 125 | arg.to_s 126 | else 127 | "_#{arg.to_s}" 128 | end 129 | when String 130 | "'#{arg}'" 131 | when Cons, Goal 132 | arg.to_prolog 133 | when Numeric 134 | arg.to_s 135 | else 136 | raise "Unknown argument: #{arg.inspect}" 137 | end 138 | end.join(', ') 139 | 140 | if @pred_name == :not_ 141 | "\\+ #{args_out}" 142 | else 143 | "#{@pred_name}(#{args_out})" 144 | end 145 | end 146 | end 147 | 148 | 149 | # Lisp 150 | class Cons < Array 151 | 152 | def initialize(car, cdr) 153 | super(2) 154 | self[0], self[1] = car, cdr 155 | end 156 | 157 | def inspect 158 | repr = proc {|x| 159 | car, cdr = x[0], x[1] 160 | if cdr.nil? then [car.inspect] 161 | elsif Cons === cdr then repr[cdr].unshift(car.inspect) 162 | else [car.inspect, '.', cdr.inspect] 163 | end 164 | } 165 | return '(' + repr[self].join(' ') + ')' 166 | end 167 | 168 | def to_prolog 169 | current = self 170 | array = [] 171 | while current 172 | array << case current[0] 173 | when :CUT then '!' 174 | when :_ then '_' 175 | else current[0].to_prolog 176 | end 177 | current = current[1] 178 | end 179 | return array.join(', ') 180 | end 181 | end 182 | 183 | 184 | class Environment 185 | 186 | def initialize 187 | @table = {} 188 | end 189 | 190 | def put(x, pair) 191 | @table[x] = pair 192 | end 193 | 194 | def get(x) 195 | return @table[x] 196 | end 197 | 198 | def delete(x) 199 | @table.delete(x) {|k| raise "#{k} not found in #{inspect}"} 200 | end 201 | 202 | def clear 203 | @table.clear 204 | end 205 | 206 | def solution 207 | @table.map do |var, env| 208 | xp = env 209 | loop { 210 | x, x_env = xp 211 | y, y_env = x_env.dereference(x) 212 | next_xp = y_env.get(x) 213 | if next_xp.nil? 214 | xp = [y, y_env] 215 | break 216 | else 217 | xp = next_xp 218 | end 219 | } 220 | [var, xp[0]] 221 | end.to_h 222 | end 223 | 224 | def dereference(t) 225 | env = self 226 | while Symbol === t 227 | p = env.get(t) 228 | break if p.nil? 229 | t, env = p 230 | end 231 | return [t, env] 232 | end 233 | 234 | def [](t) 235 | t, env = dereference(t) 236 | return case t 237 | when Goal then Goal.new(t.pred_id, t.pred_name, env[t.args]) 238 | when Cons then Cons.new(env[t[0]], env[t[1]]) 239 | when Array then t.collect {|e| env[e]} 240 | else t 241 | end 242 | end 243 | 244 | 245 | end 246 | 247 | 248 | class CallbackEnvironment 249 | 250 | def initialize(env, trail, core) 251 | @env, @trail, @core = env, trail, core 252 | end 253 | 254 | def [](t) 255 | return @env[t] 256 | end 257 | 258 | def unify(t, u) 259 | # pp "CORE " + @core 260 | return @core._unify(t, @env, u, @env, @trail, @env) 261 | end 262 | 263 | end 264 | 265 | 266 | class Database 267 | attr_reader :by_name, :by_id 268 | 269 | def initialize 270 | @by_name = { 271 | 'false' => Predicate.new(self, 'false', explicit_id: 0) 272 | } 273 | @by_id = { 274 | 0 => @by_name['false'] 275 | } 276 | @listing_enabled = false 277 | @listing = {} 278 | end 279 | 280 | def register(pred_name, skip_listing: false) 281 | pred = @by_name[pred_name] = Predicate.new(self, pred_name) 282 | @by_id[pred.id] = pred 283 | @listing[pred.id] = false if skip_listing 284 | pred 285 | end 286 | 287 | def enable_listing(flag=true) 288 | @listing_enabled = true 289 | end 290 | 291 | def append(head, body) 292 | pred = @by_id[head.pred_id] 293 | if pred.nil? 294 | raise "No such predicate for head: #{head.inspect}" 295 | end 296 | pred.clauses << [head, body] 297 | if @listing_enabled && @listing[pred.id] != false 298 | # Ruby hashes maintain insertion order 299 | @listing[pred.id] = true 300 | end 301 | end 302 | 303 | def initialize_copy(orig) 304 | super 305 | @by_id = @by_id.transform_values do |pred| 306 | pred.fork(self) 307 | end 308 | @by_name = @by_name.transform_values {|pred| @by_id[pred.id]} 309 | end 310 | 311 | def listing 312 | @listing.select{|_,v| v}.map{|k,v| @by_id[k]} 313 | end 314 | end 315 | 316 | 317 | class Core 318 | 319 | def _unify(x, x_env, y, y_env, trail, tmp_env) 320 | 321 | loop { 322 | if x == :_ 323 | return true 324 | elsif Symbol === x 325 | xp = x_env.get(x) 326 | if xp.nil? 327 | y, y_env = y_env.dereference(y) 328 | unless x == y and x_env == y_env 329 | x_env.put(x, [y, y_env]) 330 | trail << [x, x_env] unless x_env == tmp_env 331 | end 332 | return true 333 | else 334 | x, x_env = xp 335 | x, x_env = x_env.dereference(x) 336 | end 337 | elsif Symbol === y 338 | x, x_env, y, y_env = y, y_env, x, x_env 339 | else 340 | break 341 | end 342 | } 343 | 344 | if Goal === x and Goal === y 345 | return false unless x.pred_id == y.pred_id 346 | x, y = x.args, y.args 347 | end 348 | 349 | if Array === x and Array === y 350 | return false unless x.length == y.length 351 | for i in 0 ... x.length # x.each_index do |i| も可 352 | return false unless _unify(x[i], x_env, y[i], y_env, trail, tmp_env) 353 | end 354 | return true 355 | else 356 | return x == y 357 | end 358 | 359 | end 360 | 361 | 362 | def list(*x) 363 | y = nil 364 | x.reverse_each {|e| y = Cons.new(e, y)} 365 | return y 366 | end 367 | 368 | 369 | def resolve(*goals) 370 | env = Environment.new 371 | _resolve_body(list(*goals), env, [false]) { 372 | yield env 373 | } 374 | end 375 | 376 | 377 | def _resolve_body(body, env, cut) 378 | if body.nil? 379 | yield 380 | else 381 | goal, rest = body 382 | if goal == :CUT 383 | _resolve_body(rest, env, cut) { 384 | yield 385 | } 386 | cut[0] = true 387 | else 388 | d_env = Environment.new 389 | d_cut = [false] 390 | for d_head, d_body in @db.by_id[goal.pred_id].clauses 391 | break if d_cut[0] or cut[0] 392 | trail = [] 393 | if _unify_(goal, env, d_head, d_env, trail, d_env) 394 | if Proc === d_body 395 | if d_body[CallbackEnvironment.new(d_env, trail, self)] 396 | _resolve_body(rest, env, cut) { 397 | yield 398 | } 399 | end 400 | else 401 | _resolve_body(d_body, d_env, d_cut) { 402 | _resolve_body(rest, env, cut) { 403 | yield 404 | } 405 | d_cut[0] ||= cut[0] 406 | } 407 | end 408 | end 409 | for x, x_env in trail 410 | x_env.delete(x) 411 | end 412 | d_env.clear 413 | end 414 | end 415 | end 416 | end 417 | 418 | 419 | $_trace = false 420 | def trace(flag) 421 | $_trace = flag 422 | end 423 | 424 | 425 | def _unify_(x, x_env, y, y_env, trail, tmp_env) 426 | lhs, rhs = x_env[x].inspect, y.inspect if $_trace 427 | unified = _unify(x, x_env, y, y_env, trail, tmp_env) 428 | printf("\t%s %s %s\n", lhs, (unified ? "~" : "!~"), rhs) if $_trace 429 | return unified 430 | end 431 | 432 | 433 | def query(&block) 434 | goals = instance_eval(&block) 435 | goals = [goals] unless goals.is_a?(Array) 436 | results = [] 437 | 438 | resolve(*goals.map(&:to_goal)) {|env| 439 | results << env.solution 440 | } 441 | return results 442 | end 443 | 444 | 445 | def is(*syms,&block) 446 | $is_cnt ||= 0 447 | is = @db.register("IS_#{$is_cnt += 1}", skip_listing: true) 448 | raise "At least one symbol needed" unless syms.size > 0 449 | is[*syms].calls do |env| 450 | value = block.call(*syms[1..-1].map{|x| env[x]}) 451 | env.unify(syms.first, value) 452 | end 453 | is[*syms] 454 | end 455 | 456 | def method_missing(meth, *args) 457 | pred = @db.register(meth) 458 | 459 | # We only want to define the method on this specific object instance to avoid polluting global namespaces. 460 | define_singleton_method(meth){ @db.by_name[meth] } 461 | 462 | pred 463 | end 464 | 465 | def to_prolog 466 | @db.listing.map(&:to_prolog).join("\n\n") 467 | end 468 | 469 | 470 | def initialize 471 | @db = Database.new 472 | # These predicates are made available in all environments 473 | write[:X].calls{|env| print env[:X]; true} 474 | writenl[:X].calls{|env| puts env[:X]; true} 475 | nl[:X].calls{|e| puts; true} 476 | eq[:X,:Y].calls{|env| env.unify(env[:X], env[:Y])} 477 | noteq[:X,:Y].calls{|env| env[:X] != env[:Y]} 478 | atomic[:X].calls do |env| 479 | case env[:X] 480 | when Symbol, Predicate, Goal; false 481 | else true 482 | end 483 | end 484 | notatomic[:X].calls do |env| 485 | case env[:X] 486 | when Symbol, Predicate, Goal; true 487 | else false 488 | end 489 | end 490 | numeric[:X].calls{|env| Numeric === env[:X] } 491 | 492 | not_[:X].calls do |env| 493 | found_solution = false 494 | resolve(env[:X], :CUT) { found_solution = true } 495 | found_solution == false 496 | end 497 | 498 | # Enable here so the predicates above don't make it in to_prolog output 499 | @db.enable_listing 500 | end 501 | 502 | def initialize_copy(orig) 503 | super 504 | @db = @db.clone 505 | end 506 | end 507 | 508 | end 509 | -------------------------------------------------------------------------------- /lib/ruby-prolog/version.rb: -------------------------------------------------------------------------------- 1 | module RubyProlog 2 | 3 | VERSION = '2.5.0' 4 | 5 | end -------------------------------------------------------------------------------- /ruby-prolog.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'ruby-prolog/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "ruby-prolog" 8 | spec.version = RubyProlog::VERSION 9 | spec.authors = ["Preston Lee"] 10 | spec.email = ["preston.lee@prestonlee.com"] 11 | spec.description = "A pure Ruby implementation of a useful subset of Prolog." 12 | spec.summary = "A Prolog-ish Ruby DSL." 13 | spec.homepage = "http://github.com/preston/ruby-prolog" 14 | spec.license = "Apache-2.0" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", '~> 2.6.2' 22 | spec.add_development_dependency "rake", "~> 13" 23 | spec.add_development_dependency "minitest", "~> 5" 24 | spec.add_development_dependency "minitest-focus", "~> 1" 25 | end 26 | -------------------------------------------------------------------------------- /test/lib/ruby-prolog/ruby-prolog_test.rb: -------------------------------------------------------------------------------- 1 | 2 | require_relative '../../test_helper' 3 | 4 | 5 | 6 | describe RubyProlog do 7 | 8 | it 'should not pollute the global namespace with predicates.' do 9 | 10 | # We'll create numerous instances of the engine and assert they do not interfere with each other. 11 | one = RubyProlog::Core.new 12 | _( one.query{ male[:X] }.length ).must_equal 0 13 | 14 | two = RubyProlog::Core.new 15 | two.instance_eval do 16 | male[:preston].fact 17 | end 18 | _( two.query{ male[:X] }.length ).must_equal 1 19 | 20 | three = RubyProlog::Core.new 21 | _( three.query{ male[:X] }.length ).must_equal 0 22 | 23 | _( one.query{ male[:X] }.length ).must_equal 0 24 | end 25 | 26 | 27 | it 'returns hashes of solutions' do 28 | one = RubyProlog.new do 29 | foo['a', 'b'].fact 30 | foo['a', 'b'].fact 31 | foo['a', 'c'].fact 32 | foo['d', 'e'].fact 33 | foo['d', 'c'].fact 34 | end 35 | _( one.query {_= foo['a', :X] } ).must_equal [{ X: 'b' }, { X: 'b' }, { X: 'c' }] 36 | _( one.query {_= foo['a', :X], foo['d', :X] } ).must_equal [{ X: 'c' }] 37 | _(one.to_prolog.class).must_equal String 38 | end 39 | 40 | it 'works with numbers' do 41 | one = RubyProlog.new do 42 | foo[10, 20].fact 43 | foo[10, 30].fact 44 | end 45 | _( one.query {_= foo[10, :X] } ).must_equal [{ X: 20 }, { X: 30 }] 46 | 47 | _(one.to_prolog.class).must_equal String 48 | end 49 | 50 | it 'considers all predicates dynamic' do 51 | one = RubyProlog::Core.new 52 | one.instance_eval do 53 | foo[10] << [bar[20]] 54 | end 55 | _( one.query {_= foo[:X] } ).must_equal [] 56 | end 57 | 58 | it 'supports underscore' do 59 | one = RubyProlog::Core.new 60 | one.instance_eval do 61 | foo[10, 200].fact 62 | foo[10, 300].fact 63 | foo[20, 400].fact 64 | 65 | bar[50, :_].fact 66 | end 67 | _( one.query { foo[:X, :_] } ).must_equal [{X: 10}, {X: 10}, {X: 20}] 68 | _( one.query { bar[50, 99] } ).must_equal [{}] 69 | one.to_prolog 70 | end 71 | 72 | it 'supports clone' do 73 | one = RubyProlog::Core.new 74 | one.instance_eval do 75 | foo[10].fact 76 | end 77 | _( one.query {_= foo[:X] } ).must_equal [{X: 10}] 78 | 79 | two = one.clone 80 | _( one.query {_= foo[:X] } ).must_equal [{X: 10}] 81 | _( two.query {_= foo[:X] } ).must_equal [{X: 10}] 82 | 83 | one.instance_eval{ foo[20].fact } 84 | 85 | _( one.query {_= foo[:X] } ).must_equal [{X: 10}, {X: 20}] 86 | _( two.query {_= foo[:X] } ).must_equal [{X: 10}] 87 | 88 | two.instance_eval{ foo[30].fact } 89 | _( one.query {_= foo[:X] } ).must_equal [{X: 10}, {X: 20}] 90 | _( two.query {_= foo[:X] } ).must_equal [{X: 10}, {X: 30}] 91 | end 92 | 93 | it 'supports false' do 94 | db = RubyProlog.new do 95 | foo[:_] << [false] 96 | foo['x'].fact 97 | 98 | bar[:_] << [:CUT, false] 99 | bar['x'].fact 100 | 101 | baz[false].fact 102 | end 103 | 104 | _( db.query{ foo['x'] } ).must_equal [{}] 105 | _( db.query{ bar['x'] } ).must_equal [] 106 | _( db.query{ baz[false] } ).must_equal [{}] 107 | end 108 | 109 | it 'should be able to query simple family trees.' do 110 | 111 | c = RubyProlog.new do 112 | # Basic family tree relationships.. 113 | sibling[:X,:Y] << [ parent[:Z,:X], parent[:Z,:Y], noteq[:X,:Y] ] 114 | mother[:X,:Y] << [parent[:X, :Y], female[:X]] 115 | father[:X,:Y] << [parent[:X, :Y], male[:X]] 116 | 117 | grandparent[:G,:C] << [ parent[:G,:P], parent[:P,:C]] 118 | 119 | ancestor[:A, :C] << [parent[:A, :C]] 120 | ancestor[:A, :C] << [parent[:A, :X], parent[:X, :C]] 121 | 122 | mothers[:M, :C] << mother[:M, :C] 123 | mothers[:M, :C] << [mother[:M, :X], mothers[:X, :C]] 124 | 125 | fathers[:F, :C] << father[:F, :C] 126 | fathers[:F, :C] << [father[:F, :X], fathers[:X, :C]] 127 | 128 | widower[:W] << [married[:W, :X], deceased[:X], nl[deceased[:W]]] 129 | widower[:W] << [married[:X, :W], deceased[:X], nl[deceased[:W]]] 130 | 131 | # Basic parents relationships as could be stored in a typical relational database. 132 | parent['Ms. Old', 'Marge'].fact 133 | 134 | parent['Carol', 'Ron'].fact 135 | parent['Kent', 'Ron'].fact 136 | parent['Marge', 'Marcia'].fact 137 | parent['Pappy', 'Marcia'].fact 138 | 139 | parent['Marcia', 'Karen'].fact 140 | parent['Marcia', 'Julie'].fact 141 | parent['Ron', 'Karen'].fact 142 | parent['Ron', 'Julie'].fact 143 | 144 | parent['Matt', 'Silas'].fact 145 | parent['Julie', 'Silas'].fact 146 | parent['Preston', 'Cirrus'].fact # Technically our dog.. but whatever :) 147 | parent['Karen', 'Cirrus'].fact 148 | 149 | 150 | # Gender facts.. 151 | male['Preston'].fact 152 | male['Kent'].fact 153 | male['Pappy'].fact 154 | male['Ron'].fact 155 | male['Matt'].fact 156 | female['Ms. Old'].fact 157 | female['Carol'].fact 158 | female['Marge'].fact 159 | female['Marcia'].fact 160 | female['Julie'].fact 161 | female['Karen'].fact 162 | 163 | 164 | # People die :( 165 | deceased['Pappy'].fact 166 | 167 | 168 | # Let's marry some people.. 169 | married['Carol', 'Kent'].fact 170 | married['Marge', 'Pappy'].fact 171 | married['Ron', 'Marcia'].fact 172 | married['Matt', 'Julie'].fact 173 | married['Preston', 'Karen'].fact 174 | 175 | 176 | # And add some facts on personal interests.. 177 | interest['Karen', 'Music'].fact 178 | interest['Karen', 'Movies'].fact 179 | interest['Karen', 'Games'].fact 180 | interest['Karen', 'Walks'].fact 181 | interest['Preston', 'Music'].fact 182 | interest['Preston', 'Movies'].fact 183 | interest['Preston', 'Games'].fact 184 | 185 | interest['Silas', 'Games'].fact 186 | interest['Cirrus', 'Games'].fact 187 | interest['Karen', 'Walks'].fact 188 | interest['Ron', 'Walks'].fact 189 | interest['Marcia', 'Walks'].fact 190 | end 191 | 192 | # Runs some queries.. 193 | 194 | # p "Who are Silas's parents?" 195 | # Silas should have two parents: Matt and Julie. 196 | _( c.query{ parent[:P, 'Silas'] } ).must_equal [{P: 'Matt'}, {P: 'Julie'}] 197 | 198 | # p "Who is married?" 199 | # We defined 5 married facts. 200 | _( c.query{ married[:A, :B] }.length ).must_equal 5 201 | 202 | # p 'Are Karen and Julie siblings?' 203 | # Yes, through two parents. 204 | _( c.query{ sibling['Karen', 'Julie'] }.length ).must_equal 2 205 | 206 | 207 | # p "Who likes to play games?" 208 | # Four people. 209 | _( c.query{ interest[:X, 'Games'] }.length ).must_equal 4 210 | 211 | 212 | # p "Who likes to play checkers?" 213 | # Nobody. 214 | _( c.query{ interest[:X, 'Checkers'] }.length ).must_equal 0 215 | 216 | # p "Who are Karen's ancestors?" 217 | _( c.query{ ancestor[:A, 'Karen'] } ).must_equal [ 218 | {A: 'Marcia'}, 219 | {A: 'Ron'}, 220 | {A: 'Carol'}, 221 | {A: 'Kent'}, 222 | {A: 'Marge'}, 223 | {A: 'Pappy'}, 224 | ] 225 | 226 | # p "What grandparents are also widowers?" 227 | # Marge, twice, because of two grandchildren. 228 | _( c.query{_= widower[:X], grandparent[:X, :G] }.length ).must_equal 2 229 | 230 | _(c.to_prolog.class).must_equal String 231 | end 232 | 233 | 234 | it 'should be able to query simple family trees.' do 235 | 236 | c = RubyProlog.new do 237 | 238 | vendor['dell'].fact 239 | vendor['apple'].fact 240 | 241 | model['ultrasharp'].fact 242 | model['xps'].fact 243 | model['macbook'].fact 244 | model['iphone'].fact 245 | 246 | manufactures['dell', 'ultrasharp'].fact 247 | manufactures['dell', 'xps'].fact 248 | manufactures['apple', 'macbook'].fact 249 | manufactures['apple', 'iphone'].fact 250 | 251 | is_a['xps', 'laptop'].fact 252 | is_a['macbook', 'laptop'].fact 253 | is_a['ultrasharp', 'monitor'].fact 254 | is_a['iphone', 'phone'].fact 255 | 256 | kind['laptop'].fact 257 | kind['monitor'].fact 258 | kind['phone'].fact 259 | 260 | model[:M] << [manfactures[:V, :M]] 261 | 262 | vendor_of[:V, :K] << [vendor[:V], manufactures[:V, :M], is_a[:M, :K]] 263 | not_vendor_of[:V, :K] << [vendor[:V], not_[vendor_of[:V, :K]]] 264 | end 265 | 266 | _( c.query{ is_a[:K, 'laptop'] }.length ).must_equal 2 267 | _( c.query{ vendor_of[:V, 'phone'] } ).must_equal [{V: 'apple'}] 268 | _( c.query{ not_vendor_of[:V, 'phone'] } ).must_equal [{V: 'dell'}] 269 | _(c.to_prolog.class).must_equal String 270 | end 271 | 272 | 273 | it 'should solve the Towers of Hanoi problem.' do 274 | c = RubyProlog.new do 275 | 276 | move[0,:X,:Y,:Z] << :CUT # There are no more moves left 277 | move[:N,:A,:B,:C] << [ 278 | is(:M,:N){|n| n - 1}, # reads as "M IS N - 1" 279 | move[:M,:A,:C,:B], 280 | # write_info[:A,:B], 281 | move[:M,:C,:B,:A] 282 | ] 283 | write_info[:X,:Y] << [ 284 | # write["move a disc from the "], 285 | # write[:X], write[" pole to the "], 286 | # write[:Y], writenl[" pole "] 287 | ] 288 | 289 | hanoi[:N] << move[:N,"left","right","center"] 290 | end 291 | 292 | _( c.query{ hanoi[5] } ).must_equal [{}] 293 | 294 | _(c.to_prolog.class).must_equal String 295 | end 296 | 297 | it 'works on the other examples in the readme' do 298 | db = RubyProlog.new do 299 | implication['a', 'b'].fact 300 | implication['b', 'c'].fact 301 | implication['c', 'd'].fact 302 | implication['c', 'x'].fact 303 | 304 | implies[:A, :B] << implication[:A, :B] 305 | implies[:A, :B] << [ 306 | implication[:A, :Something], 307 | implies[:Something, :B] 308 | ] 309 | end 310 | 311 | _( db.query{ implication['c', :X] } ).must_equal [{ X: 'd' }, { X: 'x' }] 312 | _( db.query{ implication[:X, :_] } ).must_equal [{ X: 'a' }, { X: 'b' }, { X: 'c' }, { X: 'c' }] 313 | _( db.query{_= implies['a', :X] } ).must_equal [{ X: 'b' }, { X: 'c' }, { X: 'd' }, { X: 'x' }] 314 | 315 | _( db.query{[ implication['b', :S], implies[:S, :B] ]} ).must_equal [{:S=>"c", :B=>"d"}, {:S=>"c", :B=>"x"}] 316 | _( db.query{_= implication['b', :S], implies[:S, :B] } ).must_equal [{:S=>"c", :B=>"d"}, {:S=>"c", :B=>"x"}] 317 | 318 | # For good measure 319 | _( db.query{_= implies['a', 'b'] } ).must_equal [{}] 320 | _( db.query{_= implies['a', 'd'] } ).must_equal [{}] 321 | _( db.query{_= implies['a', 'idontexist'] } ).must_equal [] 322 | end 323 | 324 | it 'supports zero-arity predicates' do 325 | db = RubyProlog.new do 326 | data['a'].fact 327 | foo_1[] << data['a'] 328 | bar_1[].fact 329 | 330 | foo_2 << data['b'] 331 | bar_2.fact 332 | end 333 | 334 | _( db.query{ foo_1[] } ).must_equal [{}] 335 | _( db.query{ bar_1[] } ).must_equal [{}] 336 | 337 | _( db.query{ foo_2 } ).must_equal [] 338 | _( db.query{ bar_2 } ).must_equal [{}] 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'minitest/pride' 3 | require 'minitest/focus' 4 | require File.expand_path('../../lib/ruby-prolog.rb', __FILE__) --------------------------------------------------------------------------------