wick 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << File.expand_path('../../lib', __FILE__)
4
+
5
+ require 'wick/io'
6
+
7
+ Wick::IO.bind(read: $stdin, write: $stdout) do |input|
8
+ initial = {total: 0}
9
+
10
+ state = input.scan(initial) { |s, line|
11
+ if line.strip.empty?
12
+ {total: 0, message: "Total: #{s[:total]}"}
13
+ else
14
+ {total: s[:total] + line.to_f}
15
+ end
16
+ }
17
+
18
+ output = state.select { |s| s.has_key?(:message) }
19
+ .map { |s| s[:message] + "\n\n" }
20
+
21
+ output
22
+ end
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ $LOAD_PATH << File.expand_path('../../lib', __FILE__)
5
+
6
+ require 'wick/io'
7
+
8
+ Wick::IO.bind(read: $stdin, write: $stdout) do |input|
9
+ input.map { |line| "Haha! You said “#{line.chomp}”." }
10
+ end
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ unless ARGV.length >= 1
4
+ $stderr.puts "Usage: #{$0} HOST [PORT [NICK]]"
5
+ exit 1
6
+ end
7
+
8
+ $LOAD_PATH << File.expand_path('../../../lib', __FILE__)
9
+ $LOAD_PATH << File.expand_path('../lib', __FILE__)
10
+
11
+ require 'wick/io'
12
+ require 'irc_event'
13
+ require 'socket'
14
+ require 'colored'
15
+
16
+ def main
17
+ host = ARGV.fetch(0)
18
+ port = ARGV.fetch(1) { "6667" }
19
+ nick = ARGV.fetch(2) { "frippery" }
20
+
21
+ socket = TCPSocket.new(host, port)
22
+
23
+ Wick::IO.bind(
24
+ read: [socket, $stdin],
25
+ write: [socket, $stdout]
26
+ ) do |network_in, user_in|
27
+ server_events = network_in.map { |line| IRCEvent.parse(line) }
28
+
29
+ client = Client.new(nick)
30
+ client_commands = client.transform(server_events)
31
+ network_out = user_in.merge(client_commands)
32
+
33
+ ui = UI.new
34
+ user_out = ui.transform(client_commands, server_events)
35
+
36
+ [network_out, user_out]
37
+ end
38
+ end
39
+
40
+ class Client < Struct.new(:nick)
41
+ def transform(server_events)
42
+ nick_and_user = Wick.from_array([
43
+ "NICK #{nick}",
44
+ "USER #{nick} () * #{nick}"
45
+ ])
46
+
47
+ ping = server_events.select { |msg| msg.command == "PING" }
48
+ pong = ping.map { |msg| "PONG " + msg.params.join(" ") }
49
+
50
+ nick_and_user.merge(pong)
51
+ end
52
+ end
53
+
54
+ class UI
55
+ def transform(client_commands, server_events)
56
+ incoming_messages = server_events.select { |event| event.command == "PRIVMSG" }
57
+ other_events = server_events.select { |event| event.command != "PRIVMSG" }
58
+
59
+ message_lines = incoming_messages.map { |event|
60
+ channel = event.params[0]
61
+ user = "<#{event.user}>"
62
+ message = event.params[1]
63
+
64
+ "#{channel.green} #{user.yellow} #{message}"
65
+ }
66
+
67
+ message_lines
68
+ .merge(other_events.map { |event| "< #{event.line}".magenta })
69
+ .merge(client_commands.map { |line| "> #{line}".magenta })
70
+ end
71
+ end
72
+
73
+ main
@@ -0,0 +1,60 @@
1
+ # Copied from https://github.com/Nerdmaster/ruby-irc-yail/blob/develop/lib/net/yail/message_parser.rb
2
+
3
+ class IRCEvent
4
+ attr_reader :line, :nick, :user, :host, :prefix, :command, :params, :servername
5
+
6
+ USER = /\S+?/
7
+ NICK = /[\w\d\\|`'^{}\]\[-]+?/
8
+ HOST = /\S+?/
9
+ SERVERNAME = /\S+?/
10
+
11
+ # This is automatically grouped for ease of use in the parsing. Group 1 is
12
+ # the full prefix; 2, 3, and 4 are nick/user/host; 1 is also servername if
13
+ # there was no match to populate 2, 3, and 4.
14
+ PREFIX = /((#{NICK})!(#{USER})@(#{HOST})|#{SERVERNAME})/
15
+ COMMAND = /(\w+|\d{3})/
16
+ TRAILING = /\:\S*?/
17
+ MIDDLE = /(?: +([^ :]\S*))/
18
+
19
+ MESSAGE = /^(?::#{PREFIX} +)?#{COMMAND}(.*)$/
20
+
21
+ def self.parse(line)
22
+ new(line)
23
+ end
24
+
25
+ def initialize(line)
26
+ line = line.sub(/[\r\n]+$/, '')
27
+
28
+ @line = line
29
+ @params = []
30
+
31
+ if line =~ MESSAGE
32
+ matches = Regexp.last_match
33
+
34
+ @prefix = matches[1]
35
+ if (matches[2])
36
+ @nick = matches[2]
37
+ @user = matches[3]
38
+ @host = matches[4]
39
+ else
40
+ @servername = matches[1]
41
+ end
42
+
43
+ @command = matches[5]
44
+
45
+ # Args are a bit tricky. First off, we know there must be a single
46
+ # space before the arglist, so we need to strip that. Then we have to
47
+ # separate the trailing arg as it can contain nearly any character. And
48
+ # finally, we split the "middle" args on space.
49
+ arglist = matches[6].sub(/^ +/, '')
50
+ arglist.sub!(/^:/, ' :')
51
+ (middle_args, trailing_arg) = arglist.split(/ +:/, 2)
52
+ @params.push(middle_args.split(/ +/)) if middle_args
53
+ @params.push(trailing_arg) if trailing_arg
54
+ @params.compact!
55
+ @params.flatten!
56
+ end
57
+
58
+ freeze
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ require 'wick/io'
2
+ require 'socket'
3
+
4
+ require 'nice/client'
5
+ require 'nice/ui'
6
+ require 'nice/manager'
7
+
8
+ module Nice
9
+ def self.run!(host, port, nick)
10
+ client = Client.new(nick)
11
+ ui = UI.new(nick)
12
+ manager = Manager.new(client, ui)
13
+
14
+ socket = TCPSocket.new(host, port)
15
+
16
+ Wick::IO.bind(
17
+ read: [socket, $stdin],
18
+ write: [socket, $stdout]
19
+ ) do |network_in, user_in|
20
+ manager.transform(network_in, user_in)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,58 @@
1
+ require 'irc_event'
2
+
3
+ module Nice
4
+ class Client < Struct.new(:nick)
5
+ def initialize(*args)
6
+ super(*args)
7
+ freeze
8
+ end
9
+
10
+ def transform(network_in, user_commands)
11
+ server_events = network_in.map { |line| IRCEvent.parse(line) }
12
+
13
+ nick_and_user = Wick.from_array([
14
+ "NICK #{nick}",
15
+ "USER #{nick} () * #{nick}"
16
+ ])
17
+
18
+ ping = server_events.select { |msg| msg.command == "PING" }
19
+ pong = ping.map { |msg| "PONG " + msg.params.join(" ") }
20
+
21
+ outgoing = process_user_commands(user_commands)
22
+
23
+ network_out = outgoing.merge(nick_and_user).merge(pong)
24
+
25
+ server_events.log!("server_events")
26
+ network_out.log!("network_out")
27
+
28
+ [network_out, server_events]
29
+ end
30
+
31
+ def process_user_commands(user_commands)
32
+ user_commands.map { |cmd|
33
+ case cmd.action
34
+ when nil
35
+ if cmd.channel
36
+ "PRIVMSG #{cmd.channel} :#{cmd.argument}"
37
+ else
38
+ cmd.argument
39
+ end
40
+ when :me
41
+ cmd.channel && "PRIVMSG #{cmd.channel} :\x01ACTION #{cmd.argument}\x01"
42
+ when :msg
43
+ "PRIVMSG #{cmd.channel} :#{cmd.argument}"
44
+ when :join
45
+ "JOIN #{cmd.channel}"
46
+ when :part
47
+ "PART #{cmd.channel}"
48
+ when :quit
49
+ "QUIT :#{cmd.argument}"
50
+ when :raw
51
+ cmd.argument
52
+ else
53
+ nil
54
+ end
55
+ }.compact
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module Nice
2
+ class Manager
3
+ def initialize(client, ui)
4
+ @client = client
5
+ @ui = ui
6
+ freeze
7
+ end
8
+
9
+ def transform(network_in, user_in)
10
+ server_events_bus = Wick::Bus.new
11
+ user_commands_bus = Wick::Bus.new
12
+
13
+ network_out, server_events = @client.transform(network_in, user_commands_bus)
14
+ user_out, user_commands = @ui.transform(user_in, server_events_bus)
15
+
16
+ server_events_bus.consume!(server_events)
17
+ user_commands_bus.consume!(user_commands)
18
+
19
+ [network_out, user_out]
20
+ end
21
+ end
22
+ end
23
+
@@ -0,0 +1,135 @@
1
+ require 'nice/user_command'
2
+ require 'colored'
3
+
4
+ module Nice
5
+ class UI
6
+ class ChannelState < Struct.new(:joined_channels, :channel_index)
7
+ def current_channel_name
8
+ channel_index && joined_channels[channel_index]
9
+ end
10
+
11
+ def update_channel_index(inc)
12
+ ChannelState.new(
13
+ joined_channels,
14
+ (channel_index + inc) % joined_channels.length)
15
+ end
16
+
17
+ def join_channel(name)
18
+ new_list = joined_channels | [name]
19
+ new_idx = new_list.length-1
20
+
21
+ ChannelState.new(new_list, new_idx)
22
+ end
23
+
24
+ def part_channel(name)
25
+ new_list = joined_channels - [name]
26
+ new_idx = [channel_index, new_list.length-1].min
27
+
28
+ ChannelState.new(new_list, new_idx)
29
+ end
30
+ end
31
+
32
+ def initialize(username)
33
+ @username = username
34
+ freeze
35
+ end
36
+
37
+ def transform(user_in, server_events)
38
+ user_commands_without_channel = user_in.map { |line| UserCommand.parse(line) }.log!("user_commands_without_channel")
39
+
40
+ channel_state = get_channel_state(user_commands_without_channel, server_events).log!("channel_state")
41
+
42
+ user_commands = user_commands_without_channel.sampling(channel_state) { |cmd, cs|
43
+ cmd.with_channel(cs.current_channel_name)
44
+ }.log!("user_commands")
45
+
46
+ message_log = get_message_log(user_commands, server_events).log!("message log")
47
+
48
+ user_out = render_output(channel_state, message_log)
49
+
50
+ [user_out, user_commands]
51
+ end
52
+
53
+ def get_channel_state(user_commands_without_channel, server_events)
54
+ manual_changes = get_manual_state_changes(user_commands_without_channel)
55
+ automatic_changes = get_automatic_state_changes(server_events)
56
+
57
+ initial_state = ChannelState.new([], nil)
58
+
59
+ manual_changes.merge(automatic_changes)
60
+ .scan(initial_state) { |cs, change| change.call(cs) }
61
+ end
62
+
63
+ def get_manual_state_changes(user_commands_without_channel)
64
+ user_commands_without_channel.select { |cmd| cmd.action == :next or cmd.action == :prev }
65
+ .map { |cmd|
66
+ proc { |cs|
67
+ if cmd.action == :next
68
+ cs.update_channel_index(+1)
69
+ elsif cmd.action == :prev
70
+ cs.update_channel_index(-1)
71
+ end
72
+ }
73
+ }
74
+ end
75
+
76
+ def get_automatic_state_changes(server_events)
77
+ server_events.select { |event| event.user == @username }
78
+ .select { |event| event.command == "JOIN" or event.command == "PART" }
79
+ .map { |event|
80
+ proc { |cs|
81
+ if event.command == "JOIN"
82
+ cs.join_channel(event.params.first)
83
+ elsif event.command == "PART"
84
+ cs.part_channel(event.params.first)
85
+ end
86
+ }
87
+ }
88
+ end
89
+
90
+ def get_message_log(user_commands, server_events)
91
+ outgoing_messages = user_commands.select { |cmd| cmd.action.nil? }
92
+ .map { |cmd| [cmd.channel, @username, cmd.argument] }
93
+
94
+ incoming_messages = server_events.select { |event| event.command == "PRIVMSG" }
95
+ .map { |event| [event.params[0], event.user, event.params[1]] }
96
+
97
+ outgoing_messages.merge(incoming_messages).scan(Hash.new([])) { |map, triple|
98
+ channel, user, message = *triple
99
+ map.merge(channel => map[channel] + ["<#{user}>".yellow + " #{message}"])
100
+ }
101
+ end
102
+
103
+ def render_output(channel_state, message_log)
104
+ message_log.combine(channel_state) { |current_log, cs|
105
+ if current_log and cs
106
+ tabs = cs.joined_channels.map { |c|
107
+ if c == cs.current_channel_name
108
+ c.green
109
+ else
110
+ c
111
+ end
112
+ }
113
+
114
+ channel_log = current_log[cs.current_channel_name].last(20)
115
+
116
+ clear_screen + move_to(1,1) + tabs.join(" ") + "\n" + channel_log.join("\n")
117
+ else
118
+ ""
119
+ end
120
+ }
121
+ end
122
+
123
+ def clear_screen
124
+ ansi("2J")
125
+ end
126
+
127
+ def move_to(x, y)
128
+ ansi("#{x};#{y}H")
129
+ end
130
+
131
+ def ansi(str)
132
+ "\e[#{str}"
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,45 @@
1
+ module Nice
2
+ class UserCommand < Struct.new(:action, :argument, :channel)
3
+ REGEX = /
4
+ ^
5
+ \/ # leading slash
6
+ (\w+) # action
7
+ (
8
+ \s+ # space
9
+ (.+) # argument
10
+ )?
11
+ $
12
+ /x
13
+
14
+ def self.parse(line)
15
+ line = line.chomp
16
+
17
+ if match = line.match(REGEX)
18
+ action = match[1].downcase.to_sym
19
+ argument = match[3]
20
+ channel = nil
21
+
22
+ if [:msg, :join, :part].include?(action)
23
+ channel, argument = argument.split(/\s+/, 2)
24
+ end
25
+
26
+ new(action, argument, channel)
27
+ else
28
+ new(nil, line, nil)
29
+ end
30
+ end
31
+
32
+ def initialize(*args)
33
+ super(*args)
34
+ freeze
35
+ end
36
+
37
+ def with_channel(channel_name)
38
+ if channel
39
+ self
40
+ else
41
+ UserCommand.new(action, argument, channel_name)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << File.expand_path('../../../lib', __FILE__)
4
+ $LOAD_PATH << File.expand_path('../lib', __FILE__)
5
+
6
+ require 'slop'
7
+ require 'nice'
8
+
9
+ opts = Slop.parse(help: true) do
10
+ banner "Usage: #{$0} HOST [OPTIONS]"
11
+
12
+ on 'port=', 'Port to connect to', default: '6667'
13
+ on 'nick=', 'Nick (and username) to use', default: 'frippery'
14
+ on 'log=', 'Filename to log to (default: irc.log, stderr if blank)', default: 'irc.log'
15
+ end
16
+
17
+ if ARGV.length != 1
18
+ puts opts
19
+ exit 1
20
+ end
21
+
22
+ host = ARGV.fetch(0)
23
+
24
+ if opts[:log] != ''
25
+ $stderr.reopen File.open(opts[:log], 'a')
26
+ $stderr.sync = true
27
+ end
28
+
29
+ begin
30
+ Nice.run!(host, opts[:port], opts[:nick])
31
+ rescue
32
+ if $stderr.is_a?(File)
33
+ puts "Something went wrong. Check #{$stderr.path}, or run again with --log '' to output to stderr."
34
+ end
35
+
36
+ raise
37
+ end
38
+
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ unless ARGV.length >= 1
4
+ $stderr.puts "Usage: #{$0} HOST [PORT]"
5
+ exit 1
6
+ end
7
+
8
+ $LOAD_PATH << File.expand_path('../../lib', __FILE__)
9
+
10
+ require 'wick/io'
11
+ require 'socket'
12
+
13
+ socket = TCPSocket.new(ARGV[0], ARGV[1] || 23)
14
+
15
+ Wick::IO.bind(
16
+ read: [socket, $stdin],
17
+ write: [socket, $stdout]
18
+ ) do |network_in, user_in|
19
+ [user_in, network_in]
20
+ end
@@ -0,0 +1,39 @@
1
+ require 'wick/stream'
2
+ require 'wick/bus'
3
+
4
+ module Wick
5
+ START = Object.new.freeze
6
+
7
+ class << self
8
+ def from_array(array)
9
+ s = Stream.new
10
+ on_next_tick do
11
+ array.each do |item|
12
+ s << item
13
+ end
14
+ end
15
+ s
16
+ end
17
+
18
+ def run_loop!(&block)
19
+ while true
20
+ tick!
21
+ block.call()
22
+ end
23
+ end
24
+
25
+ def on_next_tick(&callback)
26
+ @tick_callbacks ||= []
27
+ @tick_callbacks.push(callback)
28
+ end
29
+
30
+ private
31
+
32
+ def tick!
33
+ return unless @tick_callbacks
34
+ while callback = @tick_callbacks.shift
35
+ callback.call()
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,8 @@
1
+ module Wick
2
+ class Bus < Stream
3
+ def consume!(stream)
4
+ stream.pipe!(self)
5
+ end
6
+ end
7
+ end
8
+
@@ -0,0 +1,41 @@
1
+ require 'wick'
2
+
3
+ module Wick
4
+ module IO
5
+ def self.bind(options, &block)
6
+ readables_array = [options[:read]].flatten
7
+ writables_array = [options[:write]].flatten
8
+
9
+ read_map = {}
10
+ readables_array.each do |io|
11
+ read_map[io] = Stream.new
12
+ end
13
+
14
+ block_arg = read_map.values
15
+
16
+ write_streams = [block.call(*block_arg)].flatten
17
+ write_map = Hash[writables_array.zip(write_streams)]
18
+
19
+ write_map.each_pair do |io, stream|
20
+ stream.each do |line|
21
+ io.puts(line)
22
+ end
23
+ end
24
+
25
+ read_map.values.each do |stream|
26
+ stream.start!
27
+ end
28
+
29
+ Wick.run_loop! do
30
+ ready = ::IO.select(read_map.keys)
31
+ ready[0].each do |io|
32
+ io.read_nonblock(1_000_000).each_line do |line|
33
+ read_map[io] << line
34
+ end
35
+ end
36
+ end
37
+ rescue EOFError
38
+ puts "A connection was closed. Shutting down."
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,125 @@
1
+ module Wick
2
+ class Stream
3
+ def initialize
4
+ @handlers = []
5
+ @start_callbacks = []
6
+
7
+ Wick.on_next_tick do
8
+ self.start!
9
+ end
10
+ end
11
+
12
+ def <<(data)
13
+ @handlers.each do |handler|
14
+ handler.call(data)
15
+ end
16
+ end
17
+
18
+ def each(&handler)
19
+ @handlers << handler
20
+ end
21
+
22
+ def at_start
23
+ s = Stream.new
24
+ @start_callbacks.push(proc { s << Wick::START })
25
+ s
26
+ end
27
+
28
+ def start!
29
+ @start_callbacks.each(&:call)
30
+ end
31
+
32
+ def map(&transformer)
33
+ s = Stream.new
34
+ self.each do |msg|
35
+ s << transformer.call(msg)
36
+ end
37
+ s
38
+ end
39
+
40
+ def flat_map(&transformer)
41
+ s = Stream.new
42
+ self.each do |msg|
43
+ transformer.call(msg).pipe!(s)
44
+ end
45
+ s
46
+ end
47
+
48
+ def select(&predicate)
49
+ s = Stream.new
50
+ self.each do |msg|
51
+ s << msg if predicate.call(msg)
52
+ end
53
+ s
54
+ end
55
+
56
+ def compact
57
+ select { |msg| not msg.nil? }
58
+ end
59
+
60
+ def merge(other)
61
+ s = Stream.new
62
+ self.pipe!(s)
63
+ other.pipe!(s)
64
+ s
65
+ end
66
+
67
+ def combine(other, &combiner)
68
+ latest_self = nil
69
+ latest_other = nil
70
+
71
+ s = Stream.new
72
+
73
+ self.each do |msg|
74
+ latest_self = msg
75
+ s << combiner.call(msg, latest_other)
76
+ end
77
+
78
+ other.each do |msg|
79
+ latest_other = msg
80
+ s << combiner.call(latest_self, msg)
81
+ end
82
+
83
+ s
84
+ end
85
+
86
+ def sampling(other, &combiner)
87
+ latest_other = nil
88
+
89
+ other.each do |msg|
90
+ latest_other = msg
91
+ end
92
+
93
+ s = Stream.new
94
+
95
+ self.each do |msg|
96
+ s << combiner.call(msg, latest_other)
97
+ end
98
+
99
+ s
100
+ end
101
+
102
+ def scan(initial, &scanner)
103
+ s = Wick.from_array([initial])
104
+ last = initial
105
+ self.each do |msg|
106
+ last = scanner.call(last, msg)
107
+ s << last
108
+ end
109
+ s
110
+ end
111
+
112
+ def log!(prefix)
113
+ self.each do |msg|
114
+ $stderr.puts("[#{prefix}] " + msg.inspect)
115
+ end
116
+ self
117
+ end
118
+
119
+ def pipe!(other)
120
+ self.each do |msg|
121
+ other << msg
122
+ end
123
+ end
124
+ end
125
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wick
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Aanand Prasad
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-04-03 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description:
15
+ email: aanand.prasad@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - examples/calculator
21
+ - examples/echo
22
+ - examples/irc/basic
23
+ - examples/irc/lib/irc_event.rb
24
+ - examples/irc/lib/nice.rb
25
+ - examples/irc/lib/nice/client.rb
26
+ - examples/irc/lib/nice/manager.rb
27
+ - examples/irc/lib/nice/ui.rb
28
+ - examples/irc/lib/nice/user_command.rb
29
+ - examples/irc/nice
30
+ - examples/telnet
31
+ - lib/wick.rb
32
+ - lib/wick/bus.rb
33
+ - lib/wick/io.rb
34
+ - lib/wick/stream.rb
35
+ homepage: https://github.com/aanand/wick
36
+ licenses: []
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ none: false
49
+ requirements:
50
+ - - ! '>='
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubyforge_project:
55
+ rubygems_version: 1.8.23
56
+ signing_key:
57
+ specification_version: 3
58
+ summary: Functional reactive programming library
59
+ test_files: []