yoga 0.0.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: a6a3b73d2db5477d58eab02a2179a42888bc0ba2
4
- data.tar.gz: 49c440508979558a68d01fb44142b267eae7e176
3
+ metadata.gz: 4999074c914d1566ee18799548c1d437ff5ad83b
4
+ data.tar.gz: 998165e1b73a1d6fef8350b7a5b5f15bfb6295ee
5
5
  SHA512:
6
- metadata.gz: 8a9cac22e56f5c19de82a070d83e94a3a825c5f4666c495d5e9dbe0d5bea87543534acbade0b545ed929985178f4508559a4cb5d3ae5f9278f5c23b3add27231
7
- data.tar.gz: 3e374cd507d6e4d44429300ea4bea0d4f4752c79570ccc6d8117a5ba280193ee2ff1ef0acce064cb4af13247e6b719c141d851aa8ef7026d8dbf2adc5c8eab37
6
+ metadata.gz: 3f0f59113580eac2ac0065651837f3e0bef3044585e48ff61e92c8b9a3cc21faac33a45ab0a98ed908d80de05adcc5bafb66a1e291ca6150c0641c25fbe25947
7
+ data.tar.gz: 9c86dc7896453ebe2e6c5bdb060f664757f9246af44a4e3ff556789b5c0851fe7648f53b8c493d3254babea180195e356226c1d544933b1382d2875e359fbf26
data/.gitignore CHANGED
@@ -6,8 +6,9 @@
6
6
  Gemfile.lock
7
7
  InstalledFiles
8
8
  _yardoc
9
- coverage
9
+ coverage/
10
10
  doc/
11
+ profile/
11
12
  lib/bundler/man
12
13
  pkg
13
14
  rdoc
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --require pry
data/.rubocop.yml ADDED
@@ -0,0 +1,40 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.3
3
+
4
+ Metrics/LineLength:
5
+ Enabled: false
6
+ Metrics/MethodLength:
7
+ Max: 15
8
+ Metrics/BlockLength:
9
+ Exclude:
10
+ - 'spec/*.rb'
11
+ - 'spec/**/*.rb'
12
+ Metrics/AbcSize:
13
+ Max: 25
14
+
15
+ Style/AlignParameters:
16
+ EnforcedStyle: with_fixed_indentation
17
+ Style/SignalException:
18
+ EnforcedStyle: only_fail
19
+ Style/AccessModifierIndentation:
20
+ EnforcedStyle: outdent
21
+ Style/Documentation:
22
+ Enabled: false
23
+ Style/Encoding:
24
+ Enabled: true
25
+ EnforcedStyle: always
26
+ AutoCorrectEncodingComment: "# encoding: utf-8\n"
27
+ Style/RegexpLiteral:
28
+ EnforcedStyle: mixed
29
+ Style/StringLiterals:
30
+ EnforcedStyle: double_quotes
31
+ Style/AlignHash:
32
+ EnforcedLastArgumentHashStyle: ignore_implicit
33
+ Style/AlignArray:
34
+ Enabled: false
35
+ Style/IndentArray:
36
+ Enabled: false
37
+ Style/Alias:
38
+ EnforcedStyle: prefer_alias_method
39
+ Style/ParallelAssignment:
40
+ Enabled: false
data/.travis.yml ADDED
@@ -0,0 +1,8 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.4.0
4
+ - 2.2.2
5
+ - 2.1.0
6
+ script:
7
+ - bundle exec rubocop --format clang
8
+ - bundle exec rspec spec
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ -m markdown
2
+ --protected
3
+ --private
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at me@medcat.me. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile CHANGED
@@ -1,4 +1,11 @@
1
- source 'https://rubygems.org'
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ source "https://rubygems.org"
2
5
 
3
6
  # Specify your gem's dependencies in yoga.gemspec
4
7
  gemspec
8
+
9
+ gem "mixture"
10
+ gem "pry"
11
+ gem "pry-stack_explorer"
data/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # Yoga
2
2
 
3
- TODO: Write a gem description
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/yoga`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
4
6
 
5
7
  ## Installation
6
8
 
7
9
  Add this line to your application's Gemfile:
8
10
 
9
- gem 'yoga'
11
+ ```ruby
12
+ gem 'yoga'
13
+ ```
10
14
 
11
15
  And then execute:
12
16
 
@@ -20,10 +24,13 @@ Or install it yourself as:
20
24
 
21
25
  TODO: Write usage instructions here
22
26
 
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
23
33
  ## Contributing
24
34
 
25
- 1. Fork it ( https://github.com/[my-github-username]/yoga/fork )
26
- 2. Create your feature branch (`git checkout -b my-new-feature`)
27
- 3. Commit your changes (`git commit -am 'Add some feature'`)
28
- 4. Push to the branch (`git push origin my-new-feature`)
29
- 5. Create a new Pull Request
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/yoga. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
+
data/Rakefile CHANGED
@@ -1,2 +1,9 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
1
4
  require "bundler/gem_tasks"
5
+ require "rspec/core/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
2
8
 
9
+ task default: :spec
data/lib/yoga.rb CHANGED
@@ -1,5 +1,16 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
1
4
  require "yoga/version"
5
+ require "yoga/errors"
6
+ require "yoga/utils"
7
+ require "yoga/location"
8
+ require "yoga/node"
9
+ require "yoga/parser"
10
+ require "yoga/scanner"
11
+ require "yoga/token"
2
12
 
13
+ # A parser and scanner helper.
3
14
  module Yoga
4
15
  # Your code goes here...
5
16
  end
@@ -0,0 +1,61 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ # An error originating from the Yoga class. All behavior here is private
6
+ # to the Yoga module.
7
+ #
8
+ # @api private
9
+ # @private
10
+ class Error < ::StandardError
11
+ # Initialize the error.
12
+ #
13
+ # @api private
14
+ def initialize(data)
15
+ data.each { |k, v| instance_variable_set(:"@#{k}", v) }
16
+ super(generate_message)
17
+ end
18
+
19
+ private
20
+
21
+ # Generates a message for the exception.
22
+ #
23
+ # @return [::String]
24
+ def generate_message
25
+ message
26
+ end
27
+ end
28
+
29
+ # An error that has a corresponding location information.
30
+ #
31
+ # @api private
32
+ class LocationError < Error
33
+ attr_reader :location
34
+ end
35
+
36
+ # An error that occurred with parsing.
37
+ #
38
+ # @api private
39
+ class ParseError < LocationError; end
40
+
41
+ # An unexpected token was encountered while parsing.
42
+ #
43
+ # @api private
44
+ class UnexpectedTokenError < ParseError
45
+ # (see Error#generate_message)
46
+ private def generate_message
47
+ "Unexpected #{@got}, expected one of #{@expected.to_a.join(', ')} " \
48
+ "at #{@location}"
49
+ end
50
+ end
51
+
52
+ # A shift was requested, but was invalid.
53
+ #
54
+ # @api private
55
+ class InvalidShiftError < ParseError
56
+ # (see Error#generate_message)
57
+ private def generate_message
58
+ "Parser shifted, but no tokens remain at #{@location}"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,178 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ # A location in a file. This can be used for debugging purposes.
6
+ class Location
7
+ # The file the location is positioned in. This should just be a string
8
+ # that uniquely identifies the file from all possible values of the
9
+ # location.
10
+ #
11
+ # @return [::String]
12
+ attr_reader :file
13
+
14
+ # The line the location is on. This can be a range of lines, or a single
15
+ # line.
16
+ #
17
+ # @return [::Range]
18
+ attr_reader :line
19
+
20
+ # The column the location on. This can be a range of columns, or a single
21
+ # column.
22
+ #
23
+ # @return [::Range]
24
+ attr_reader :column
25
+
26
+ # A "hash" of the location. This is a number that is meant to roughly
27
+ # represent the value of this location. Used primarily for the Hash
28
+ # class.
29
+ #
30
+ # @api private
31
+ # @return [::Numeric]
32
+ attr_reader :hash
33
+
34
+ # Creates a "default" location. This is a location that can
35
+ # be given if the location is unknown.
36
+ #
37
+ # @param file [::String] The file. See {#file}.
38
+ # @return [Location]
39
+ def self.default(file = "<unknown>")
40
+ new(file, 0..0, 0..0)
41
+ end
42
+
43
+ # Initialize the location with the given information.
44
+ #
45
+ # @param file [::String] The file. See {#file}.
46
+ # @param line [::Range, ::Numeric] The line. See {#line}.
47
+ # @param column [::Range, ::Numeric] The column. See {#column}.
48
+ def initialize(file, line, column)
49
+ @file = file.freeze
50
+ @line = ensure_range(line).freeze
51
+ @column = ensure_range(column).freeze
52
+ @hash = [@file, @line, @column].hash
53
+ freeze
54
+ end
55
+
56
+ # Creates a string representing this location in a file.
57
+ #
58
+ # @return [::String]
59
+ def to_s
60
+ "#{file}:#{range(line)}.#{range(column)}"
61
+ end
62
+
63
+ # Pretty inspect.
64
+ #
65
+ # @return [::String]
66
+ def inspect
67
+ "#<#{self.class} #{self}>"
68
+ end
69
+
70
+ # Determines if the other object is equal to the current instance. This
71
+ # checks `equal?` first to determine if they are strict equals; otherwise,
72
+ # it checks if the other is a {Location}. If it is, it checks that the
73
+ # properties are equal.
74
+ #
75
+ # @example
76
+ # a # => #<Location file="a" line=1..1 column=5..20>
77
+ # a == a # => true
78
+ # @example
79
+ # a # => #<Location file="a" line=1..1 column=5..20>
80
+ # b # => #<Location file="a" line=1..1 column=5..20>
81
+ # a == b # => true
82
+ # @example
83
+ # a # => #<Location file="a" line=1..1 column=5..20>
84
+ # b # => #<Location file="b" line=1..1 column=6..20>
85
+ # a == b # => false
86
+ #
87
+ # @param other [Object]
88
+ # @return [Boolean]
89
+ def ==(other)
90
+ equal?(other) || other.is_a?(Location) && @file == other.file &&
91
+ @line == other.line && @column == other.column
92
+ end
93
+
94
+ # Unions this location with another location. This creates a new location,
95
+ # with the two locations combined. A conflict in the file name causes an
96
+ # error to be raised.
97
+ #
98
+ # @example
99
+ # a = Location.new("a", 1..1, 5..10)
100
+ # a.union(a) # => #<Location file="a" line=1..1 column=5..10>
101
+ # a.union(a) == a # => true
102
+ # a.union(a).equal?(a) # => false
103
+ # @example
104
+ # a = Location.new("a", 1..1, 5..10)
105
+ # b = Location.new("a", 1..1, 6..20)
106
+ # a.union(b) # => #<Location file="a" line=1..1 column=5..20>
107
+ # @example
108
+ # a = Location.new("a", 1..5, 3..10)
109
+ # b = Location.new("b", 1..3, 2..20)
110
+ # a.union(b) # => #<Location file="a" line=1..5 column=2..20>
111
+ # @example
112
+ # a # => #<Location ...>
113
+ # b # => #<Location ...>
114
+ # a.union(b) == b.union(a)
115
+ #
116
+ # @raise [::ArgumentError] if other isn't a {Location}.
117
+ # @raise [::ArgumentError] if other's file isn't the receiver's file.
118
+ # @param others [Location]
119
+ # @return [Location]
120
+ def union(*others)
121
+ others.each do |other|
122
+ fail ArgumentError, "Expected #{self.class}, got #{other.class}" \
123
+ unless other.is_a?(Location)
124
+ fail ArgumentError, "Expected other to have the same file" unless
125
+ file == other.file
126
+ end
127
+
128
+ line = construct_range([@line, *others.map(&:line)])
129
+ column = construct_range([@column, *others.map(&:column)])
130
+
131
+ Location.new(@file, line, column)
132
+ end
133
+
134
+ alias_method :|, :union
135
+
136
+ private
137
+
138
+ # Creates a range from a list of ranges. This takes the lowest starting
139
+ # value, and the greatest ending value, and creates a new range from that.
140
+ #
141
+ # @param from [<::Range<::Numeric>>]
142
+ # @return [::Range<::Numeric>]
143
+ def construct_range(from)
144
+ first = from.map(&:first).min
145
+ last = from.map(&:last).max
146
+
147
+ first..last
148
+ end
149
+
150
+ # Ensures that the given value is a range. If it's numeric, it's turned
151
+ # into a range with the same starting and ending values. If it's a range,
152
+ # it's returned. Otherwise, it fails.
153
+ #
154
+ # @param value [::Numeric, ::Range] The value to turn into a range.
155
+ # @return [::Range]
156
+ def ensure_range(value)
157
+ case value
158
+ when ::Numeric
159
+ value..value
160
+ when ::Range
161
+ value
162
+ else
163
+ fail ArgumentError, "Unexpected #{value.class}, expected Range"
164
+ end
165
+ end
166
+
167
+ # Returns a string version of the range. If there is no distance between
168
+ # the starting and ending for the given range, it returns a string of the
169
+ # value. Otherwise, it returns `<starting>-<ending>`.
170
+ #
171
+ # @param value [::Range<::Numeric>]
172
+ # @return [::String]
173
+ def range(value)
174
+ return value.first.to_s unless value.first != value.last
175
+ "#{value.first}-#{value.last}"
176
+ end
177
+ end
178
+ end
data/lib/yoga/node.rb ADDED
@@ -0,0 +1,63 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require "mixture"
5
+
6
+ module Yoga
7
+ # A parser node. This can be subclassed, or used as is.
8
+ class Node
9
+ include Mixture::Model
10
+
11
+ # @!attribute [r] location
12
+ # The location of the node. This is normally dependant on the locations
13
+ # of the tokens that make up this node.
14
+ #
15
+ # @return [Yoga::Location]
16
+ attribute :location, type: Yoga::Location
17
+
18
+ # Initialize the node with the given attributes. The `:kind` and
19
+ # `:location` attributes are required.
20
+ #
21
+ # @param attributes [Hash] The attributes.
22
+ # @option attributes [::Symbol] :kind The kind of the node.
23
+ # @option attributes [Yoga::Location] :location The location of the node.
24
+ def initialize(attributes)
25
+ self.attributes = attributes
26
+ freeze
27
+ end
28
+
29
+ # Creates a new node with the updated attributes. If any unknown
30
+ # attributes are used, it fails.
31
+ #
32
+ # @param (see #initialize)
33
+ # @option (see #initialize)
34
+ def update(attributes)
35
+ self.class.new(self.attributes.merge(attributes))
36
+ end
37
+
38
+ # Returns whether or not the other object is equal to this one.
39
+ # If the other object is this opbject, it returns true; otherwise,
40
+ # if the other object is an instance of this object's class, and
41
+ # the attributes are equal, it returns true; otherwise, it returns
42
+ # false.
43
+ #
44
+ # @param other [::Object]
45
+ # @return [Boolean]
46
+ def ==(other)
47
+ equal?(other) || (other.is_a?(self.class) && \
48
+ attributes == other.attributes)
49
+ end
50
+
51
+ # Prevents all calls to {#update}. This is used on nodes that should
52
+ # never be updated. This is a stop-gap measure for incorrectly
53
+ # configured projects. This, in line with {#update}, creates
54
+ # a duplicate node.
55
+ #
56
+ # @return [Yoga::Node]
57
+ def prevent_update
58
+ node = dup
59
+ node.singleton_class.send(:undef_method, :update)
60
+ node
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,33 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yoga/parser/helpers"
5
+
6
+ module Yoga
7
+ # A parsing helper.
8
+ #
9
+ # This uses the `@tokens` and `@_root` instance variables.
10
+ module Parser
11
+ # Initialize the parser.
12
+ #
13
+ # @param tokens [::Enumerable<Yoga::Token>]
14
+ def initialize(tokens)
15
+ @tokens = tokens
16
+ end
17
+
18
+ # Performs the parsing.
19
+ #
20
+ # @return [Yoga::Node]
21
+ def call
22
+ @_root ||= parse_root
23
+ end
24
+
25
+ # Internal ruby construct.
26
+ #
27
+ # @private
28
+ # @api private
29
+ def self.included(base)
30
+ base.send :include, Parser::Helpers
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,234 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ module Parser
6
+ # helpers that manage the state of the parser. This can be things like
7
+ # peeking, shifting, and erroring.
8
+ module Helpers
9
+ # The class methods for helpers for the parser. These are defined
10
+ # on the class of the parser.
11
+ module ClassMethods
12
+ # The default value for a switch. This matches no tokens and
13
+ # performs no actions. This cannot be modified.
14
+ #
15
+ # @return [{::Symbol => ::Proc}]
16
+ DEFAULT_SWITCH = {}.freeze
17
+
18
+ # Predefines a switch logic. This is used to do set checking for the
19
+ # keys instead of array checking. This reduces the time it takes for
20
+ # a switch statement to be performed.
21
+ #
22
+ # @param name [::Symbol] The name of the switch. This is used in the
23
+ # method to look up the switch.
24
+ # @param mapping [{::Symbol, <::Symbol> => ::Proc}, nil] A
25
+ # mapping of symbol(s) to callables. If no value is given, then it
26
+ # returns the current switch for the given name.
27
+ # @return [{::Symbol => {::Symbol => ::Proc}}]
28
+ #
29
+ # @overload switch(name)
30
+ # Retrieves the switch map for the given name. This is basically
31
+ # a lookup table for the peek token. The keys represent the peek
32
+ # token kind, and the values are the actions that should be taken
33
+ # for the given token kind.
34
+ #
35
+ # @param name [::Symbol] The name of the switch map. This is
36
+ # typically the same as the first set name and the node name for
37
+ # the rule.
38
+ # @return [{::Symbol => ::Proc}] The switch map.
39
+ # @overload switch(name, mapping)
40
+ # Sets the switch map for the given name. This does some internal
41
+ # mapping before finalizing the mapping. First, it converts all
42
+ # values into a proc; then, it expands all array (or enumerable)
43
+ # keys into seperate entries. It then sets the first set using the
44
+ # name of the switch as the name of the first set, and the keys of
45
+ # the switch map as the first set.
46
+ #
47
+ # @example
48
+ # Parser.switch(:Definition,
49
+ # :":" => proc { parse_directive },
50
+ # :enum => proc { parse_enum },
51
+ # :def => proc { parse_function },
52
+ # :class => proc { parse_class },
53
+ # :struct => proc { parse_struct })
54
+ #
55
+ # @param name [::Symbol] The name of the switch map. A first set
56
+ # with the same name is created as well.
57
+ # @param mapping [{::Symbol, ::Enumerable<::Symbol> =>
58
+ # ::Proc, ::Symbol}] The mapping.
59
+ # @return [void]
60
+ def switch(name, mapping = nil)
61
+ @_switches ||= {}
62
+ name = name.intern
63
+ return @_switches[name] || DEFAULT_SWITCH unless mapping
64
+ switch = {}
65
+ mapping.each do |k, v|
66
+ v = v.is_a?(::Proc) ? v : proc { |*a| send(v, *a) }
67
+ Array(k).each { |n| switch[n] = v }
68
+ end
69
+
70
+ first(name, switch.keys)
71
+ @_switches[name] = switch
72
+ end
73
+
74
+ # The first sets. The key is the name of a node, and the value
75
+ # is a set of all tokens that can be at the start of that node.
76
+ #
77
+ # @return [{::Symbol => ::Set<::Symbol>}]
78
+ def firsts
79
+ @firsts ||= Hash.new { |h, k| h[k] = Set.new }
80
+ end
81
+
82
+ # @overload first(name)
83
+ # Retrieves the first set for the given node name. If none exists,
84
+ # a `KeyError` is raised.
85
+ #
86
+ # @api private
87
+ # @param name [::Symbol] The name of the node to look up the first
88
+ # set for.
89
+ # @return [::Set<::Symbol>] The first set
90
+ # @raise [::KeyError] If the node has no defined first set.
91
+ # @overload first(name, tokens)
92
+ # Merges the given tokens into the first set of the name of the
93
+ # node given.
94
+ #
95
+ # @api private
96
+ # @param name [::Symbol] The name of the node that the first set is
97
+ # for.
98
+ # @param tokens [<::Symbol>] The tokens that act as the first set
99
+ # for the node.
100
+ # @return [void]
101
+ def first(name, tokens = nil)
102
+ if tokens
103
+ firsts[name] = Set.new(Array(tokens))
104
+ else
105
+ firsts.fetch(name)
106
+ end
107
+ end
108
+ end
109
+
110
+ # Peeks to the next token. If peeking would cause a `StopIteration`
111
+ # error, it instead returns the last value that was peeked.
112
+ #
113
+ # @return [Yoga::Token]
114
+ def peek
115
+ if next?
116
+ @_last = @tokens.peek
117
+ else
118
+ @_last
119
+ end
120
+ end
121
+
122
+ # Checks if the next token exists. It does this by checking for a
123
+ # `StopIteration` error. This is actually really slow, but there's
124
+ # not much else I can do.
125
+ #
126
+ # @return [::Boolean]
127
+ def next?
128
+ @tokens.peek
129
+ true
130
+ rescue StopIteration
131
+ false
132
+ end
133
+
134
+ # Switches the function executed based on the given nodes. If the
135
+ # peeked node matches one of the mappings, it calls the resulting
136
+ # block or method.
137
+ #
138
+ # If the peeked token is not in the map, then {#error} is called.
139
+ #
140
+ # @see ClassMethods#switch
141
+ # @param name [::Symbol] The name of the switch to use.
142
+ # @return [::Object] The result of calling the block.
143
+ def switch(name, *param)
144
+ switch = self.class.switch(name)
145
+ block = switch.fetch(peek.kind) { error(switch.keys) }
146
+ instance_exec(*param, &block)
147
+ end
148
+
149
+ # rubocop:disable Metrics/CyclomaticComplexity
150
+ # rubocop:disable Metrics/PerceivedComplexity
151
+
152
+ # "Collects" a set of nodes until a terminating token. It yields
153
+ # until the peek is the token.
154
+ #
155
+ # @param ending [::Symbol] The terminating token.
156
+ # @param join [::Symbol, nil] The token that joins each of the
157
+ # children. This is the comma between arguments.
158
+ # @return [::Array] The collected nodes from the yielding process.
159
+ def collect(ending, join = nil)
160
+ children = []
161
+ join = Utils.flatten_into_set([join]) if join
162
+ ending = Utils.flatten_into_set([ending]) if ending
163
+
164
+ return [] if (ending && peek?(ending)) || (!ending && !join)
165
+
166
+ children << yield
167
+ while (join && expect(join)) && !(ending && peek?(ending))
168
+ children << yield
169
+ end
170
+
171
+ children
172
+ end
173
+
174
+ # rubocop:enable Metrics/CyclomaticComplexity
175
+ # rubocop:enable Metrics/PerceivedComplexity
176
+
177
+ # Checks to see if any of the given kinds includes the next token.
178
+ #
179
+ # @param tokens [<::Symbol>] The possible kinds.
180
+ # @return [::Boolean]
181
+ def peek?(*tokens)
182
+ tokens = Utils.flatten_into_set(tokens)
183
+ tokens.include?(peek.kind)
184
+ end
185
+
186
+ # Shifts to the next token, and returns the old token.
187
+ #
188
+ # @return [Yoga::Token]
189
+ def shift
190
+ @tokens.next
191
+ rescue ::StopIteration
192
+ fail InvalidShiftError, location: peek.location
193
+ end
194
+
195
+ # Sets up an expectation for a given token. If the next token is
196
+ # an expected token, it shifts, returning the token; otherwise,
197
+ # it {#error}s with the token information.
198
+ #
199
+ # @param tokens [<::Symbol>] The expected tokens.
200
+ # @return [Yoga::Token]
201
+ def expect(*tokens)
202
+ tokens = Utils.flatten_into_set(tokens)
203
+ return shift if peek?(*tokens)
204
+ error(tokens.flatten)
205
+ end
206
+
207
+ # Retrieves the first set for the given node name.
208
+ #
209
+ # @param name [::Symbol]
210
+ # @return [::Set<::Symbol>]
211
+ def first(name)
212
+ self.class.first(name)
213
+ end
214
+
215
+ # Errors, noting the expected tokens, the given token, the location
216
+ # of given tokens. It does this by failing.
217
+ #
218
+ # @param tokens [<::Symbol>] The expected tokens.
219
+ # @return [void]
220
+ def error(tokens)
221
+ fail Yoga::UnexpectedTokenError, expected: tokens, got: peek.kind,
222
+ location: peek.location
223
+ end
224
+
225
+ # Internal ruby construct.
226
+ #
227
+ # @private
228
+ # @api private
229
+ def self.included(base)
230
+ base.extend ClassMethods
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,156 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ # A scanner. This performs scanning over a series of tokens.
6
+ # It is built to lazily scan whenever it is required, instead
7
+ # of all at once. This integrates nicely with the parser.
8
+ module Scanner
9
+ # Initializes the scanner with the given source. Once the
10
+ # source is set, it shouldn't be changed.
11
+ #
12
+ # @param source [::String] The source.
13
+ def initialize(source)
14
+ @source = source
15
+ @line = 1
16
+ @last_line_at = 0
17
+ end
18
+
19
+ # @overload call(&block)
20
+ # For every token that is scanned, the block is yielded to.
21
+ #
22
+ # @yieldparam token [Scanner::Token]
23
+ # @return [self]
24
+ # @overload call
25
+ # Returns an enumerable over the tokens in the scanner.
26
+ #
27
+ # @return [::Enumerable<Scanner::Token>]
28
+ def call
29
+ return to_enum(:call) unless block_given?
30
+ @scanner = StringScanner.new(@source)
31
+ @line = 1
32
+
33
+ until @scanner.eos?
34
+ value = scan
35
+ yield value if value.is_a?(Token)
36
+ end
37
+
38
+ yield Token.eof(location)
39
+ self
40
+ end
41
+
42
+ # The scanning method. This should return one of two values: a {Token},
43
+ # or `true`. `nil` should _never_ be returned. This performs an
44
+ # incremental scan of the document; it returns one token at a time. If
45
+ # something matched, but should not emit a token, `true` should be
46
+ # returned. The implementing class should mark this as private or
47
+ # protected.
48
+ #
49
+ # @abstract
50
+ # Please implement this method in order to make the class a scanner.
51
+ # @return [Yoga::Token, true]
52
+ def scan
53
+ fail NotImplementedError, "Please implement #{self.class}#scan"
54
+ end
55
+
56
+ private
57
+
58
+ # Returns a location at the given location. If a size is given, it reduces
59
+ # the column number by the size and returns the size from that.
60
+ #
61
+ # @example
62
+ # @scanner.string # => "hello"
63
+ # @line # => 1
64
+ # @scanner.charpos # => 5
65
+ # location # => #<Yoga::Location <anon>:1.6>
66
+ # location(5) # => #<Yoga::Location <anon>:1.1-6
67
+ # @param size [::Numeric] The size of the token.
68
+ # @return [Yoga::Location]
69
+ def location(size = 0)
70
+ start = (@scanner.charpos - @last_line_at) + 1
71
+ column = (start - size)..start
72
+ Location.new(file, current_line, column)
73
+ end
74
+
75
+ # Creates a scanner token with the given name and source. This grabs the
76
+ # location using {#location}, setting the size to the size of the source
77
+ # text. The source is frozen before initializing the token.
78
+ #
79
+ # @example
80
+ # emit(:<, "<") # => #<Yoga::Token kind=:< source="<">
81
+ # @return [Yoga::Token]
82
+ def emit(kind, source = @scanner[0])
83
+ Token.new(kind.freeze, source.freeze, location(source.length))
84
+ end
85
+
86
+ # Attempts to match the given token. The first argument can be a string,
87
+ # a symbol, or a regular expression. If the matcher is a symbol, it's
88
+ # coerced into a regular expression, with a forward negative assertion for
89
+ # any alphanumeric characters, to prevent partial matches (see
90
+ # {#symbol_negative_assertion}). If the matcher is a regular expression,
91
+ # it is left alone. Otherwise, `#to_s` is called and passed to
92
+ # `Regexp.escape`. If the text is matched at the current position, a token
93
+ # is returned; otherwise, nil is returned.
94
+ #
95
+ # @param matcher [::Symbol, ::Regexp, #to_s]
96
+ # @param kind [::Symbol] The kind of token to emit. This defaults to a
97
+ # symbol version of the matcher.
98
+ # @return [Yoga::Token, nil]
99
+ def match(matcher, kind = :"#{matcher}")
100
+ matcher = case matcher
101
+ when ::Symbol then /#{::Regexp.escape(matcher.to_s)}#{symbol_negative_assertion}/
102
+ when ::Regexp then matcher
103
+ else /#{::Regexp.escape(matcher.to_s)}/
104
+ end
105
+
106
+ ((kind && emit(kind)) || true) if @scanner.scan(matcher)
107
+ end
108
+
109
+ # A regular expression to match all kinds of lines. All of them.
110
+ #
111
+ # @return [::Regexp]
112
+ LINE_MATCHER = /\r\n|\n\r|\n|\r/
113
+
114
+ # Matches a line. This is separate in order to allow internal logic,
115
+ # such as line counting and caching, to be performed.
116
+ #
117
+ # @return [Boolean] If the line was matched.
118
+ def match_line(kind = false)
119
+ match(LINE_MATCHER, kind).tap do |t|
120
+ break unless t
121
+ @line += 1
122
+ @last_line_at = @scanner.charpos
123
+ end
124
+ end
125
+
126
+ # Returns the number of lines that have been covered so far in the scanner.
127
+ # I recommend replacing this with an instance variable that caches the
128
+ # result of it, so that whenever you scan a new line, it just increments
129
+ # the line count.
130
+ #
131
+ # @return [::Numeric]
132
+ def current_line
133
+ # @scanner.string[0..@scanner.charpos].scan(/\A|\r\n|\n\r|\n|\r/).size
134
+ @line
135
+ end
136
+
137
+ # The negative assertion used for converting a symbol matcher to a regular
138
+ # expression. This is used to prevent premature matching of other
139
+ # identifiers. For example, if `module` is a keyword, and `moduleA` is
140
+ # an identifier, this negative assertion allows the following expression
141
+ # to properly match as such: `match(:module) || module(/[a-zA-Z], :IDENT)`.
142
+ #
143
+ # @return [#to_s]
144
+ def symbol_negative_assertion
145
+ "(?![a-zA-Z])"
146
+ end
147
+
148
+ # The file of the scanner. This can be overwritten to provide a descriptor
149
+ # for the file.
150
+ #
151
+ # @return [::String]
152
+ def file
153
+ @file ||= "<anon>"
154
+ end
155
+ end
156
+ end
data/lib/yoga/token.rb ADDED
@@ -0,0 +1,68 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ # A token that is used in scanning. This is emitted when a lexical
6
+ # structure is encountered; e.g., for a `{`.
7
+ class Token
8
+ # The kind of the token. For any explicit matches (e.g. `"{"`),
9
+ # this is the exact match (e.g. `:"{"`). For any regular matches
10
+ # (e.g. an identifier), this is in all caps (e.g. `:IDENTIFIER`).
11
+ #
12
+ # @return [::Symbol]
13
+ attr_reader :kind
14
+
15
+ # The lexeme that this token is associated. This is what the token
16
+ # matched directly from the source.
17
+ #
18
+ # @return [::String]
19
+ attr_reader :value
20
+
21
+ # The exact location the token was taken from. This only spans one
22
+ # line.
23
+ #
24
+ # @return [Location]
25
+ attr_reader :location
26
+
27
+ # A "hash" of the token. This is a number that is meant to roughly
28
+ # represent the value of this token. Used primarily for the Hash
29
+ # class.
30
+ #
31
+ # @api private
32
+ # @return [::Numeric]
33
+ attr_reader :hash
34
+
35
+ # Creates an `EOF`-kind token. This creates the token at the given
36
+ # location.
37
+ #
38
+ # @param location [Location]
39
+ # @return [Token]
40
+ def self.eof(location = Location.default)
41
+ new(:EOF, "", location)
42
+ end
43
+
44
+ # Initializes the token with the given kind, value, and location.
45
+ #
46
+ # @param kind [::Symbol] The kind of token. See {#kind}.
47
+ # @param value [::String] The value of the token. See {#value}.
48
+ # @param location [Location] The location of the token. See {#location}.
49
+ def initialize(kind, value, location)
50
+ @kind = kind
51
+ @value = value.freeze
52
+ @location = location
53
+ @hash = [@kind, @value, @location].hash
54
+ freeze
55
+ end
56
+
57
+ # Determines if this object is equal to another object. It first checks
58
+ # for equality using `equal?`, then checks if the other is a token. If
59
+ # the other object is a token, it checks that the attributes equate.
60
+ #
61
+ # @param other [::Object]
62
+ # @return [Boolean]
63
+ def ==(other)
64
+ equal?(other) || other.is_a?(Token) && @kind == other.kind &&
65
+ @value == other.value && @location == other.location
66
+ end
67
+ end
68
+ end
data/lib/yoga/utils.rb ADDED
@@ -0,0 +1,37 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Yoga
5
+ # Utilities used with the Yoga module.
6
+ #
7
+ # @api private
8
+ module Utils
9
+ module_function
10
+
11
+ # Takes an array of tokens or set/array of tokens and turns it into a
12
+ # single set.
13
+ #
14
+ # @param tokens [<Yoga::Token, ::Set<Yoga::Token>, ::Enumerable>]
15
+ # The array to flatten into a set.
16
+ # @return [::Set<Yoga::Token>]
17
+ def flatten_into_set(tokens)
18
+ flatten_into_array(tokens).to_set
19
+ end
20
+
21
+ # Takes an array of tokens or a set/array of tokens and turns it into a
22
+ # single array.
23
+ #
24
+ # @param tokens [<Yoga::Token>, ::Set<Yoga::Token>, ::Enumerable]
25
+ # The array to flatten into a set.
26
+ # @return [<Yoga::Token>]
27
+ def flatten_into_array(tokens)
28
+ tokens.flat_map do |part|
29
+ if part.is_a?(::Enumerable)
30
+ flatten_into_array(part)
31
+ else
32
+ part
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/yoga/version.rb CHANGED
@@ -1,3 +1,9 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
1
4
  module Yoga
2
- VERSION = "0.0.1"
5
+ # The version of the module.
6
+ #
7
+ # @return [::String]
8
+ VERSION = "0.2.0"
3
9
  end
data/script/bench ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require "benchmark/ips"
6
+ require "ruby-prof"
7
+ require "yoga/utils"
8
+
9
+ VALUES = [
10
+ [[[[:x], :a], :b]],
11
+ [[Set[[:a], :x], :a]],
12
+ [[[[[[[[[[:x], :a], :c], :d], :e], :f], :d], :a], :a]]
13
+ ].freeze
14
+
15
+ if ARGV[0] == "mark"
16
+ Benchmark.ips do |bench|
17
+ VALUES.each_with_index do |e, i|
18
+ bench.report(i) do |times|
19
+ i = 0
20
+ while i < times
21
+ Yoga::Utils.flatten_into_set(e)
22
+ i += 1
23
+ end
24
+ end
25
+ end
26
+ end
27
+ else
28
+ result = RubyProf.profile do
29
+ VALUES.each do |e|
30
+ 100_000.times do
31
+ Yoga::Utils.flatten_into_set(e)
32
+ end
33
+ end
34
+ end
35
+
36
+ printer = RubyProf::MultiPrinter.new(result)
37
+ printer.print(path: "profile/flatten", profile: "flatten")
38
+ end
data/yoga.gemspec CHANGED
@@ -1,23 +1,30 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path("../lib", __FILE__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'yoga/version'
5
+ require "yoga/version"
5
6
 
6
7
  Gem::Specification.new do |spec|
7
8
  spec.name = "yoga"
8
9
  spec.version = Yoga::VERSION
9
10
  spec.authors = ["Jeremy Rodi"]
10
- spec.email = ["redjazz96@gmail.com"]
11
- spec.summary = %q{A lexer.}
12
- spec.description = %q{A lexer.}
13
- spec.homepage = ""
14
- spec.license = "MIT"
11
+ spec.email = ["me@medcat.me"]
15
12
 
16
- spec.files = `git ls-files -z`.split("\x0")
13
+ spec.summary = "Ruby scanner and parser helpers."
14
+ spec.homepage = "https://github.com/medcat/yoga"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "bin"
17
20
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
- spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
21
  spec.require_paths = ["lib"]
20
22
 
21
- spec.add_development_dependency "bundler", "~> 1.6"
22
- spec.add_development_dependency "rake"
23
+ spec.add_dependency "mixture", "~> 0.6"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.13"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "rspec", "~> 3.0"
28
+ spec.add_development_dependency "simplecov", "~> 0.13"
29
+ spec.add_development_dependency "rubocop", "~> 0.47"
23
30
  end
metadata CHANGED
@@ -1,61 +1,130 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yoga
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Rodi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-06-16 00:00:00.000000000 Z
11
+ date: 2017-03-08 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mixture
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - "~>"
18
32
  - !ruby/object:Gem::Version
19
- version: '1.6'
33
+ version: '1.13'
20
34
  type: :development
21
35
  prerelease: false
22
36
  version_requirements: !ruby/object:Gem::Requirement
23
37
  requirements:
24
38
  - - "~>"
25
39
  - !ruby/object:Gem::Version
26
- version: '1.6'
40
+ version: '1.13'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rake
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
- - - ">="
45
+ - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: '0'
47
+ version: '10.0'
34
48
  type: :development
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
- - - ">="
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.13'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.13'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.47'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
39
95
  - !ruby/object:Gem::Version
40
- version: '0'
41
- description: A lexer.
96
+ version: '0.47'
97
+ description:
42
98
  email:
43
- - redjazz96@gmail.com
99
+ - me@medcat.me
44
100
  executables: []
45
101
  extensions: []
46
102
  extra_rdoc_files: []
47
103
  files:
48
104
  - ".gitignore"
105
+ - ".rspec"
106
+ - ".rubocop.yml"
107
+ - ".travis.yml"
108
+ - ".yardopts"
109
+ - CODE_OF_CONDUCT.md
49
110
  - Gemfile
50
111
  - LICENSE.txt
51
112
  - README.md
52
113
  - Rakefile
53
114
  - lib/yoga.rb
115
+ - lib/yoga/errors.rb
116
+ - lib/yoga/location.rb
117
+ - lib/yoga/node.rb
118
+ - lib/yoga/parser.rb
119
+ - lib/yoga/parser/helpers.rb
120
+ - lib/yoga/scanner.rb
121
+ - lib/yoga/token.rb
122
+ - lib/yoga/utils.rb
54
123
  - lib/yoga/version.rb
124
+ - script/bench
55
125
  - yoga.gemspec
56
- homepage: ''
57
- licenses:
58
- - MIT
126
+ homepage: https://github.com/medcat/yoga
127
+ licenses: []
59
128
  metadata: {}
60
129
  post_install_message:
61
130
  rdoc_options: []
@@ -73,9 +142,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
142
  version: '0'
74
143
  requirements: []
75
144
  rubyforge_project:
76
- rubygems_version: 2.2.2
145
+ rubygems_version: 2.5.2
77
146
  signing_key:
78
147
  specification_version: 4
79
- summary: A lexer.
148
+ summary: Ruby scanner and parser helpers.
80
149
  test_files: []
81
- has_rdoc: