paco 0.1.0 → 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
  SHA256:
3
- metadata.gz: 874b690b05bae4b9627aab0da0d248b020de705fbbbc88be1dc6e08afdeb8bc5
4
- data.tar.gz: 4d69df9a10c651470acc62b297c0c628ab80afa2f508c3f0588c24e811b14000
3
+ metadata.gz: ae17ec48820da8d99722dd87e2f834cc238497c948dff292490665cdeb16fbc1
4
+ data.tar.gz: 30046ad4c3203dcf35430c9512df303f008fe513c11f92e06137a7131ee586fe
5
5
  SHA512:
6
- metadata.gz: 6c5ed079f9a70a4eb11dc754ed24b59d1b50ee9e5a29baf36ab9f61cb040c6609284f8d4b9d6f3044d1ab26e8617a349b4b3811026c97b87c731d423422576d5
7
- data.tar.gz: b1afa018f1646c3421e21d01be36cd052468cb824843dc66dd38cabd92a92aee734470a08fd5b169c5bbdadab196440543e1e14419322079e50cb0b31b82fd49
6
+ metadata.gz: bf9fb9a162ab9c8292047942ebf8cc57e6a0382fa680beed1802129f6abd07d2483a24ba734f260c0fef9ba948e54ac9cf9635bc49abbbc457d2d91c6c5a9fb7
7
+ data.tar.gz: 0d4ef03c2022c26ae813d1d3f925b539c6cb7e2e715f27f965a80f9b8809e7a4c6d6d78bb6c5086603f3585aaf44e6035b5f72892078d44d63f4cfd5ca83509b
data/CHANGELOG.md CHANGED
@@ -5,9 +5,55 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog],
6
6
  and this project adheres to [Semantic Versioning].
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.0] - 2021-12-28
9
9
 
10
- ## [0.1.0]
10
+ ### Added
11
+
12
+ - Callstack collection for debugging. ([@skryukov])
13
+
14
+ Pass `with_callstack: true` to the `Paco::Parser#parse` method to collect a callstack while parsing. To examine the callstack catch the `ParseError` exception:
15
+
16
+ ```ruby
17
+ begin
18
+ string("Paco").parse("Paco!", with_callstack: true)
19
+ rescue Paco::ParseError => e
20
+ pp e.callstack.stack # You will probably want to use `binding.irb` or `binding.pry`
21
+ end
22
+ ```
23
+
24
+ - `Paco::Combinators.index` method. ([@skryukov])
25
+
26
+ Call `Paco::Combinators.index` to get `Paco::Index` representing the current offset into the parse without consuming the input.
27
+ `Paco::Index` has a 0-based character offset attribute `:pos` and 1-based `:line` and `:column` attributes.
28
+
29
+ ```ruby
30
+ index.parse("Paco") #=> #<struct Paco::Index pos=0, line=1, column=1>
31
+ ```
32
+
33
+ - RSpec matcher `#parse`. ([@skryukov])
34
+
35
+ Add `require "paco/rspec"` to `spec_helper.rb` to enable a special RSpec matcher `#parse`:
36
+
37
+ ```ruby
38
+ subject { string("Paco") }
39
+
40
+ it { is_expected.to parse("Paco") } # just checks if parser succeeds
41
+ it { is_expected.to parse("Paco").as("Paco") } # checks if parser result is eq to value passed to `#as`
42
+ it { is_expected.to parse("Paco").fully } # checks if parser result is the same as value passed to `#parse`
43
+ ```
44
+
45
+ ### Changed
46
+
47
+ - `Paco::Combinators.seq_map` merged into `Paco::Combinators.seq`. ([@skryukov])
48
+ - `Paco::Combinators.sep_by_1` renamed to `Paco::Combinators.sep_by!`. ([@skryukov])
49
+
50
+ ### Fixed
51
+
52
+ - `Paco::Combinators::Char#regexp` now uses `\A` instead of `^`. ([@skryukov])
53
+ - `include Paco` now works inside `irb`. ([@skryukov])
54
+ - `Paco::Combinators#not_followed_by` and `Paco::Combinators#seq` now don't consume input on error. ([@skryukov])
55
+
56
+ ## [0.1.0] - 2021-12-12
11
57
 
12
58
  ### Added
13
59
 
@@ -15,7 +61,8 @@ and this project adheres to [Semantic Versioning].
15
61
 
16
62
  [@skryukov]: https://github.com/skryukov
17
63
 
18
- [Unreleased]: https://github.com/skryukov/paco/compare/v0.1.0...HEAD
64
+ [Unreleased]: https://github.com/skryukov/paco/compare/v0.2.0...HEAD
65
+ [0.2.0]: https://github.com/skryukov/paco/compare/v0.1.0...v0.2.0
19
66
  [0.1.0]: https://github.com/skryukov/paco/commits/v0.1.0
20
67
 
21
68
  [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
data/README.md CHANGED
@@ -1,26 +1,83 @@
1
1
  # Paco
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/paco.svg)](https://rubygems.org/gems/paco)
4
+ [![Build](https://github.com/skryukov/paco/workflows/Build/badge.svg)](https://github.com/skryukov/paco/actions)
5
+
3
6
  Paco is a parser combinator library inspired by Haskell's [Parsec] and [Parsimmon].
4
7
 
5
- ## Installation
8
+ "But I don't need to write another JSON parser or a new language, why do I need your library then?"
6
9
 
7
- Add this line to your application's Gemfile:
10
+ Well, most probably you don't. But I can think of rare cases when you do. Say, you need to write a validation for [git branch names].
11
+
12
+ You can go with easy-peasy regex:
8
13
 
9
14
  ```ruby
10
- gem "paco"
15
+ branch_name_regex = /^(?!\/|.*(?:[\/.]\.|\/\/|@{|\\|\.lock$|[\/.]$))[^\040\177 ~^:?*\[]+$/
16
+
17
+ branch_name_regex.match?("feature/branch-validation")
18
+ ```
19
+
20
+ With Paco, you can go with a little more verbose version of that rule:
21
+
22
+ ```ruby
23
+ module BranchNameParser
24
+ extend Paco
25
+
26
+ class << self
27
+ def parse(input)
28
+ parser.parse(input)
29
+ end
30
+
31
+ def parser
32
+ lookahead(none_of("/")).next(valid_chars.join)
33
+ end
34
+
35
+ def valid_chars
36
+ any_char.not_followed_by(invalid_sequences).at_least(1)
37
+ end
38
+
39
+ def invalid_sequences
40
+ alt(invalid_chars, invalid_endings)
41
+ end
42
+
43
+ def invalid_chars
44
+ alt(
45
+ string("/."),
46
+ string(".."),
47
+ string("//"),
48
+ string("@{"),
49
+ string("\\\\"),
50
+ one_of("\040\177 ~^:?*\\[")
51
+ )
52
+ end
53
+
54
+ def invalid_endings
55
+ seq(
56
+ alt(string(".lock"), one_of("/.")),
57
+ eof
58
+ )
59
+ end
60
+ end
61
+ end
62
+
63
+ BranchNameParser.parse("feature/branch-validation")
11
64
  ```
12
65
 
13
- And then execute:
66
+ Easy? Not really, but there is a chance you can read it. 😅
67
+
68
+ See [API documentation](docs/paco.md), [examples](examples) and [specs](spec) for more info on usage.
14
69
 
15
- $ bundle install
70
+ <a href="https://evilmartians.com/"><img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
16
71
 
17
- Or install it yourself as:
72
+ ## Installation
18
73
 
19
- $ gem install paco
74
+ Add to your `Gemfile`:
20
75
 
21
- ## Usage
76
+ ```ruby
77
+ gem "paco"
78
+ ```
22
79
 
23
- See [API documentation](docs/paco.md), [examples](examples) and [specs](spec).
80
+ And then run `bundle install`.
24
81
 
25
82
  ## Development
26
83
 
@@ -32,6 +89,11 @@ To install this gem onto your local machine, run `bundle exec rake install`.
32
89
 
33
90
  Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/paco.
34
91
 
92
+ ## Alternatives
93
+
94
+ - [parslet] - A small (but featureful) PEG based parser library.
95
+ - [parsby] — Parser combinator library for Ruby inspired by Haskell's Parsec.
96
+
35
97
  ## License
36
98
 
37
99
  The gem is available as open source under the terms of the [MIT License].
@@ -39,4 +101,6 @@ The gem is available as open source under the terms of the [MIT License].
39
101
  [MIT License]: https://opensource.org/licenses/MIT
40
102
  [Parsec]: https://github.com/haskell/parsec
41
103
  [Parsimmon]: https://github.com/jneen/parsimmon
104
+ [parslet]: https://github.com/kschiess/parslet
42
105
  [parsby]: https://github.com/jolmg/parsby
106
+ [git branch names]: https://git-scm.com/docs/git-check-ref-format#_description
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paco
4
+ class Callstack
5
+ attr_reader :stack
6
+
7
+ def initialize
8
+ @stack = []
9
+ @depth = 0
10
+ end
11
+
12
+ def failure(**params)
13
+ @depth -= 1
14
+ @stack << params.merge(status: :failure, depth: @depth)
15
+ end
16
+
17
+ def start(**params)
18
+ @depth += 1
19
+ @stack << params.merge(status: :start, depth: @depth)
20
+ end
21
+
22
+ def success(**params)
23
+ @depth -= 1
24
+ @stack << params.merge(status: :success, depth: @depth)
25
+ end
26
+ end
27
+ end
@@ -32,7 +32,7 @@ module Paco
32
32
  # @param [String] matcher
33
33
  # @return [Paco::Parser]
34
34
  def string(matcher)
35
- Parser.new(matcher) do |ctx, parser|
35
+ Parser.new("string(#{matcher.inspect})") do |ctx, parser|
36
36
  src = ctx.read(matcher.length)
37
37
  parser.failure(ctx) if src != matcher
38
38
 
@@ -46,9 +46,10 @@ module Paco
46
46
  # When `group` is specified, it returns only the text in the specific regexp match group.
47
47
  # @param [Regexp] regexp
48
48
  # @return [Paco::Parser]
49
+ # @param [Integer] group
49
50
  def regexp(regexp, group: 0)
50
- anchored_regexp = Regexp.new("^(?:#{regexp.source})", regexp.options)
51
- Parser.new(regexp.inspect) do |ctx, parser|
51
+ anchored_regexp = Regexp.new("\\A(?:#{regexp.source})", regexp.options)
52
+ Parser.new("regexp(#{regexp.inspect})") do |ctx, parser|
52
53
  match = anchored_regexp.match(ctx.read_all)
53
54
  parser.failure(ctx) if match.nil?
54
55
 
@@ -61,7 +62,7 @@ module Paco
61
62
  # @param [Regexp] regexp
62
63
  # @return [Paco::Parser]
63
64
  def regexp_char(regexp)
64
- satisfy(regexp.inspect) { |char| regexp.match?(char) }
65
+ satisfy("regexp_char(#{regexp.inspect})") { |char| regexp.match?(char) }
65
66
  end
66
67
 
67
68
  # Returns a parser that looks for exactly one character from passed
@@ -69,7 +70,7 @@ module Paco
69
70
  # @param [String, Array<String>] matcher
70
71
  # @return [Paco::Parser]
71
72
  def one_of(matcher)
72
- satisfy(matcher.to_s) { |char| matcher.include?(char) }
73
+ satisfy("one_of(#{matcher})") { |char| matcher.include?(char) }
73
74
  end
74
75
 
75
76
  # Returns a parser that looks for exactly one character _NOT_ from passed
@@ -77,7 +78,7 @@ module Paco
77
78
  # @param [String, Array<String>] matcher
78
79
  # @return [Paco::Parser]
79
80
  def none_of(matcher)
80
- satisfy("not #{matcher}") { |char| !matcher.include?(char) }
81
+ satisfy("none_of(#{matcher})") { |char| !matcher.include?(char) }
81
82
  end
82
83
 
83
84
  # Returns a parser that consumes and returns the next character of the input.
@@ -90,7 +91,7 @@ module Paco
90
91
  # @return [Paco::Parser]
91
92
  def remainder
92
93
  memoize do
93
- Parser.new("remainder of the input") do |ctx, parser|
94
+ Parser.new("remainder") do |ctx, parser|
94
95
  result = ctx.read_all
95
96
  ctx.pos += result.length
96
97
  result
@@ -147,7 +148,7 @@ module Paco
147
148
  memoize { alt(newline, eof) }
148
149
  end
149
150
 
150
- # Alias for `Paco::Combinators.regexp(/[a-z]/i)`.
151
+ # Alias for `Paco::Combinators.regexp_char(/[a-z]/i)`.
151
152
  # @return [Paco::Parser]
152
153
  def letter
153
154
  memoize { regexp_char(/[a-z]/i) }
@@ -156,16 +157,16 @@ module Paco
156
157
  # Alias for `Paco::Combinators.regexp(/[a-z]+/i)`.
157
158
  # @return [Paco::Parser]
158
159
  def letters
159
- memoize { seq(letter, letter.many).fmap { |x| x.flatten.join } }
160
+ memoize { regexp(/[a-z]+/i) }
160
161
  end
161
162
 
162
163
  # Alias for `Paco::Combinators.regexp(/[a-z]*/i)`.
163
164
  # @return [Paco::Parser]
164
165
  def opt_letters
165
- memoize { letters | succeed("") }
166
+ memoize { regexp(/[a-z]*/i) }
166
167
  end
167
168
 
168
- # Alias for `Paco::Combinators.regexp(/[0-9]/)`.
169
+ # Alias for `Paco::Combinators.regexp_char(/[0-9]/)`.
169
170
  # @return [Paco::Parser]
170
171
  def digit
171
172
  memoize { regexp_char(/[0-9]/) }
@@ -174,13 +175,13 @@ module Paco
174
175
  # Alias for `Paco::Combinators.regexp(/[0-9]+/)`.
175
176
  # @return [Paco::Parser]
176
177
  def digits
177
- memoize { seq(digit, digit.many).fmap { |x| x.flatten.join } }
178
+ memoize { regexp(/[0-9]+/) }
178
179
  end
179
180
 
180
181
  # Alias for `Paco::Combinators.regexp(/[0-9]*/)`.
181
182
  # @return [Paco::Parser]
182
183
  def opt_digits
183
- memoize { digits | succeed("") }
184
+ memoize { regexp(/[0-9]*/) }
184
185
  end
185
186
 
186
187
  # Alias for `Paco::Combinators.regexp(/\s+/)`.
@@ -1,22 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
-
5
3
  require "paco/combinators/char"
4
+ require "paco/memoizer"
6
5
 
7
6
  module Paco
8
7
  module Combinators
9
- def self.extended(base)
10
- base.extend MonitorMixin
11
- base.extend Char
12
- end
13
-
14
- def self.included(base)
15
- base.include MonitorMixin
16
- base.include Char
17
- end
8
+ include Char
9
+ extend Char
18
10
 
19
- extend self
11
+ module_function
20
12
 
21
13
  # Returns a parser that runs the passed `parser` without consuming the input, and
22
14
  # returns `null` if the passed `parser` _does not match_ the input. Fails otherwise.
@@ -28,10 +20,11 @@ module Paco
28
20
  begin
29
21
  parser._parse(ctx)
30
22
  rescue ParseError
31
- ctx.pos = start_pos
32
23
  nil
33
24
  else
34
25
  pars.failure(ctx)
26
+ ensure
27
+ ctx.pos = start_pos
35
28
  end
36
29
  end
37
30
  end
@@ -39,7 +32,7 @@ module Paco
39
32
  # Returns a parser that doesn't consume any input and always returns `result`.
40
33
  # @return [Paco::Parser]
41
34
  def succeed(result)
42
- Parser.new { result }
35
+ Parser.new("succeed(#{result})") { result }
43
36
  end
44
37
 
45
38
  # Returns a parser that doesn't consume any input and always fails with passed `message`.
@@ -54,7 +47,7 @@ module Paco
54
47
  # @param [Paco::Parser] parser
55
48
  # @return [Paco::Parser]
56
49
  def lookahead(parser)
57
- Parser.new do |ctx|
50
+ Parser.new("lookahead(#{parser.desc})") do |ctx|
58
51
  start_pos = ctx.pos
59
52
  parser._parse(ctx)
60
53
  ctx.pos = start_pos
@@ -68,7 +61,7 @@ module Paco
68
61
  def alt(*parsers)
69
62
  raise ArgumentError, "no parsers specified" if parsers.empty?
70
63
 
71
- Parser.new do |ctx|
64
+ Parser.new("alt(#{parsers.map(&:desc).join(", ")})") do |ctx|
72
65
  result = nil
73
66
  last_error = nil
74
67
  start_pos = ctx.pos
@@ -86,26 +79,25 @@ module Paco
86
79
 
87
80
  # Accepts one or more parsers, and returns a parser that expects them
88
81
  # to match in order, returns an array of all their results.
82
+ # If `block` specified, passes results of the `parses` as an arguments
83
+ # to a `block`, and at the end returns its result.
89
84
  # @param [Array<Paco::Parser>] parsers
90
85
  # @return [Paco::Parser]
91
86
  def seq(*parsers)
92
87
  raise ArgumentError, "no parsers specified" if parsers.empty?
93
88
 
94
- Parser.new do |ctx|
95
- parsers.map { |parser| parser._parse(ctx) }
89
+ result = Parser.new("seq(#{parsers.map(&:desc).join(", ")})") do |ctx|
90
+ start_pos = ctx.pos
91
+ begin
92
+ parsers.map { |parser| parser._parse(ctx) }
93
+ rescue ParseError => e
94
+ ctx.pos = start_pos
95
+ raise e
96
+ end
96
97
  end
97
- end
98
+ return result unless block_given?
98
99
 
99
- # Returns a parser that matches all `parsers` sequentially, and passes
100
- # their results as an arguments to a `block`, and at the end returns its result.
101
- # @param [Array<Paco::Parser>] parsers
102
- # @return [Paco::Parser]
103
- def seq_map(*parsers, &block)
104
- raise ArgumentError, "no parsers specified" if parsers.empty?
105
-
106
- seq(*parsers).fmap do |results|
107
- block.call(*results)
108
- end
100
+ result.fmap { |results| yield(*results) }
109
101
  end
110
102
 
111
103
  # Accepts a block that returns a parser, which is evaluated the first time the parser is used.
@@ -121,7 +113,8 @@ module Paco
121
113
  # @param [Paco::Parser] separator
122
114
  # @return [Paco::Parser]
123
115
  def sep_by(parser, separator)
124
- alt(sep_by_1(parser, separator), succeed([]))
116
+ alt(sep_by!(parser, separator), succeed([]))
117
+ .with_desc("sep_by(#{parser.desc}, #{separator.desc})")
125
118
  end
126
119
 
127
120
  # Returns a parser that expects one or more matches for `parser`,
@@ -129,11 +122,11 @@ module Paco
129
122
  # @param [Paco::Parser] parser
130
123
  # @param [Paco::Parser] separator
131
124
  # @return [Paco::Parser]
132
- def sep_by_1(parser, separator)
133
- seq_map(parser, many(separator.next(parser))) do |first, arr|
134
- [first] + arr
135
- end
125
+ def sep_by!(parser, separator)
126
+ seq(parser, many(separator.next(parser))) { |first, arr| [first] + arr }
127
+ .with_desc("sep_by!(#{parser.desc}, #{separator.desc})")
136
128
  end
129
+ alias_method :sep_by_1, :sep_by!
137
130
 
138
131
  # Expects the parser `before` before `parser` and `after` after `parser. Returns the result of the parser.
139
132
  # @param [Paco::Parser] before
@@ -148,13 +141,10 @@ module Paco
148
141
  # @param [Paco::Parser] parser
149
142
  # @return [Paco::Parser]
150
143
  def many(parser)
151
- Parser.new do |ctx|
144
+ Parser.new("many(#{parser.desc})") do |ctx|
152
145
  results = []
153
- # last_pos = ctx.pos
154
146
  loop do
155
147
  results << parser._parse(ctx)
156
- # raise ArgumentError, "smth wrong" if last_pos == ctx.pos
157
- # last_pos = ctx.pos
158
148
  rescue ParseError
159
149
  break
160
150
  end
@@ -169,15 +159,16 @@ module Paco
169
159
  alt(parser, succeed(nil))
170
160
  end
171
161
 
162
+ # Returns parser that returns `Paco::Index` representing
163
+ # the current offset into the parse without consuming the input.
164
+ # @return [Paco::Parser]
165
+ def index
166
+ Parser.new { |ctx| ctx.index }
167
+ end
168
+
172
169
  # Helper used for memoization
173
170
  def memoize(&block)
174
- key = block.source_location
175
- synchronize do
176
- @_paco_memoized ||= {}
177
- return @_paco_memoized[key] if @_paco_memoized.key?(key)
178
-
179
- @_paco_memoized[key] = block.call
180
- end
171
+ Memoizer.memoize(block.source_location, &block)
181
172
  end
182
173
  end
183
174
  end
data/lib/paco/context.rb CHANGED
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require "paco/callstack"
4
+ require "paco/index"
5
+
2
6
  module Paco
3
7
  class Context
4
- attr_reader :input, :last_pos, :pos
8
+ attr_reader :input, :last_pos, :callstack
9
+ attr_accessor :pos
5
10
 
6
- def pos=(np)
7
- # TODO: is that needed?
8
- @last_pos = @pos
9
- @pos = np
10
- end
11
-
12
- def initialize(input, pos = 0)
11
+ def initialize(input, pos: 0, with_callstack: false)
13
12
  @input = input
14
13
  @pos = pos
14
+ @callstack = Callstack.new if with_callstack
15
15
  end
16
16
 
17
17
  def read(n)
@@ -19,22 +19,33 @@ module Paco
19
19
  end
20
20
 
21
21
  def read_all
22
- input[pos..-1]
22
+ input[pos..]
23
23
  end
24
24
 
25
25
  def eof?
26
26
  pos >= input.length
27
27
  end
28
28
 
29
+ # @param [Integer] from
30
+ # @return [Paco::Index]
29
31
  def index(from = nil)
30
- from ||= pos
31
- lines = input[0..from].lines
32
-
33
- {
34
- line: lines.length,
35
- column: lines[-1]&.length || 0,
36
- pos: from
37
- }
32
+ Index.calculate(input: input, pos: from || pos)
33
+ end
34
+
35
+ # @param [Paco::Parser] parser
36
+ def failure_parse(parser)
37
+ @callstack&.failure(pos: pos, parser: parser.desc)
38
+ end
39
+
40
+ # @param [Paco::Parser] parser
41
+ def start_parse(parser)
42
+ @callstack&.start(pos: pos, parser: parser.desc)
43
+ end
44
+
45
+ # @param [Object] result
46
+ # @param [Paco::Parser] parser
47
+ def success_parse(result, parser)
48
+ @callstack&.success(pos: pos, result: result, parser: parser.desc)
38
49
  end
39
50
  end
40
51
  end
data/lib/paco/index.rb ADDED
@@ -0,0 +1,15 @@
1
+ module Paco
2
+ Index = Struct.new(:pos, :line, :column) do
3
+ # @param [String] input
4
+ # @param [Integer] pos
5
+ def self.calculate(input:, pos:)
6
+ raise ArgumentError, "`pos` must be a non-negative integer" if pos < 0
7
+ raise ArgumentError, "`pos` is grater then input length" if pos > input.length
8
+
9
+ lines = input[0..pos].lines
10
+ line = lines.empty? ? 1 : lines.length
11
+ column = lines.last&.length || 1
12
+ new(pos, line, column)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Paco
6
+ module Memoizer
7
+ extend MonitorMixin
8
+
9
+ class << self
10
+ def memoize(key, &block)
11
+ synchronize do
12
+ @paco_memoized ||= {}
13
+ return @paco_memoized[key] if @paco_memoized.key?(key)
14
+
15
+ @paco_memoized[key] = block.call
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,28 +1,29 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Paco
3
4
  class Error < StandardError; end
4
5
 
5
6
  class ParseError < Error
7
+ attr_reader :ctx, :pos, :expected
8
+
6
9
  # @param [Paco::Context] ctx
7
10
  def initialize(ctx, expected)
8
11
  @ctx = ctx
9
12
  @pos = ctx.pos
10
13
  @expected = expected
14
+ end
11
15
 
12
- # TODO: make this possible to show every parsing message? or last n?
13
- # puts ""
14
- # puts "#{ctx.pos}/#{ctx.input.length}: #{ctx.input[ctx.last_pos..ctx.pos]}"
15
- # puts "expected: #{expected}"
16
- # puts ""
16
+ def callstack
17
+ ctx.callstack
17
18
  end
18
19
 
19
20
  def message
20
- index = @ctx.index(@pos)
21
+ index = ctx.index(pos)
21
22
  <<~MSG
22
- Parsing error
23
- line #{index[:line]}, column #{index[:column]}:
24
- unexpected #{@ctx.input[@pos] || "end of file"}
25
- expecting #{@expected}
23
+ \nParsing error
24
+ line #{index.line}, column #{index.column}:
25
+ unexpected #{ctx.eof? ? "end of file" : ctx.input[pos].inspect}
26
+ expecting #{expected}
26
27
  MSG
27
28
  end
28
29
  end
data/lib/paco/parser.rb CHANGED
@@ -6,36 +6,47 @@ module Paco
6
6
  class Parser
7
7
  attr_reader :desc
8
8
 
9
+ # @param [String] desc
9
10
  def initialize(desc = "", &block)
10
11
  @desc = desc
11
12
  @block = block
12
13
  end
13
14
 
14
- def parse(input)
15
- ctx = input.is_a?(Context) ? input : Context.new(input)
15
+ # @param [String] desc
16
+ # @return [Paco::Parser]
17
+ def with_desc(desc)
18
+ @desc = desc
19
+ self
20
+ end
21
+
22
+ # @param [String, Paco::Context] input
23
+ # @param [true, false] with_callstack
24
+ def parse(input, with_callstack: false)
25
+ ctx = input.is_a?(Context) ? input : Context.new(input, with_callstack: with_callstack)
16
26
  skip(Paco::Combinators.eof)._parse(ctx)
17
27
  end
18
28
 
29
+ # @param [Paco::Context] ctx
19
30
  def _parse(ctx)
20
- @block.call(ctx, self)
21
- # TODO: add ability for debugging
22
- # puts ""
23
- # puts "#{@block.source_location} succeed."
24
- # puts "#{ctx.input.length}/#{ctx.pos}: " + ctx.input[ctx.last_pos..ctx.pos].inspect
25
- # puts ""
26
- # res
31
+ ctx.start_parse(self)
32
+ res = @block.call(ctx, self)
33
+ ctx.success_parse(res, self)
34
+ res
27
35
  end
28
36
 
29
37
  # Raises ParseError
30
38
  # @param [Paco::Context] ctx
31
39
  # @raise [Paco::ParseError]
32
40
  def failure(ctx)
41
+ ctx.failure_parse(self)
33
42
  raise ParseError.new(ctx, desc), "", []
34
43
  end
35
44
 
36
45
  # Returns a new parser which tries `parser`, and if it fails uses `other`.
46
+ # @param [Paco::Parser] other
47
+ # @return [Paco::Parser]
37
48
  def or(other)
38
- Parser.new do |ctx|
49
+ Parser.new("or(#{desc}, #{other.desc})") do |ctx|
39
50
  _parse(ctx)
40
51
  rescue ParseError
41
52
  other._parse(ctx)
@@ -47,7 +58,7 @@ module Paco
47
58
  # @param [Poco::Parser] other
48
59
  # @return [Paco::Parser]
49
60
  def skip(other)
50
- Paco::Combinators.seq(self, other).fmap { |results| results[0] }
61
+ Paco::Combinators.seq(self, other).fmap { |results| results[0] }.with_desc("#{desc}.skip(#{other.desc})")
51
62
  end
52
63
  alias_method :<, :skip
53
64
 
@@ -55,14 +66,15 @@ module Paco
55
66
  # @param [Poco::Parser] other
56
67
  # @return [Paco::Parser]
57
68
  def next(other)
58
- bind { other }
69
+ Paco::Combinators.seq(self, other).fmap { |results| results[1] }
70
+ .with_desc("#{desc}.next(#{other.desc})")
59
71
  end
60
72
  alias_method :>, :next
61
73
 
62
74
  # Transforms the output of `parser` with the given block.
63
75
  # @return [Paco::Parser]
64
76
  def fmap(&block)
65
- Parser.new do |ctx|
77
+ Parser.new("#{desc}.fmap") do |ctx|
66
78
  block.call(_parse(ctx))
67
79
  end
68
80
  end
@@ -74,7 +86,7 @@ module Paco
74
86
  # with the other Paco::Combinators.
75
87
  # @return [Paco::Parser]
76
88
  def bind(&block)
77
- Parser.new do |ctx|
89
+ Parser.new("#{desc}.bind") do |ctx|
78
90
  block.call(_parse(ctx))._parse(ctx)
79
91
  end
80
92
  end
@@ -139,7 +151,7 @@ module Paco
139
151
  raise ArgumentError, "invalid attributes: min `#{min}`, max `#{max}`"
140
152
  end
141
153
 
142
- Parser.new do |ctx|
154
+ Parser.new("#{desc}.times(#{min}, #{max})") do |ctx|
143
155
  results = min.times.map { _parse(ctx) }
144
156
  (max - min).times.each do
145
157
  results << _parse(ctx)
@@ -154,7 +166,7 @@ module Paco
154
166
  # Returns a parser that runs `parser` at least `num` times,
155
167
  # and returns an array of the results.
156
168
  def at_least(num)
157
- Paco::Combinators.seq_map(times(num), many) do |head, rest|
169
+ Paco::Combinators.seq(times(num), many) do |head, rest|
158
170
  head + rest
159
171
  end
160
172
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec::Matchers.define(:parse) do |input|
4
+ chain :as do |expected_output = nil, &block|
5
+ @expected = expected_output
6
+ @block = block
7
+ end
8
+
9
+ chain :fully do
10
+ @expected = input
11
+ end
12
+
13
+ match do |parser|
14
+ @result = parser.parse(input)
15
+ return @block.call(@result) if @block
16
+ return @expected == @result if defined?(@expected)
17
+
18
+ true
19
+ rescue Paco::ParseError => e
20
+ @error_message = e.message
21
+ false
22
+ end
23
+
24
+ failure_message do |subject|
25
+ msg = "expected output of parsing #{input.inspect} with #{subject.inspect} to"
26
+ was = (@result ? "was #{@result.inspect}" : "raised an error #{@error_message}")
27
+ return "#{msg} meet block conditions, but it didn't. It #{was}" if @block
28
+ return "#{msg} equal #{@expected.inspect}, but it #{was}" if defined?(@expected)
29
+
30
+ "expected #{subject.inspect} to successfully parse #{input.inspect}, but it #{was}"
31
+ end
32
+
33
+ failure_message_when_negated do |subject|
34
+ msg = "expected output of parsing #{input.inspect} with #{subject.inspect} not to"
35
+ return "#{msg} meet block conditions, but it did" if @block
36
+ return "#{msg} equal #{@expected.inspect}" if defined?(@expected)
37
+
38
+ "expected #{subject.inspect} to not parse #{input.inspect}, but it did"
39
+ end
40
+
41
+ description do
42
+ return "parse #{input.inspect} with block conditions" if @block
43
+ return "parse #{input.inspect} as #{@expected.inspect}" if defined?(@expected)
44
+
45
+ "parse #{input.inspect}"
46
+ end
47
+ end
data/lib/paco/rspec.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Utils for testing Paco
4
+ require "paco/rspec/parse_matcher"
data/lib/paco/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Paco
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/paco.rb CHANGED
@@ -4,7 +4,6 @@ require "paco/version"
4
4
  require "paco/parse_error"
5
5
  require "paco/context"
6
6
  require "paco/parser"
7
- require "paco/combinators"
8
7
 
9
8
  module Paco
10
9
  def self.extended(base)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paco
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-12 00:00:00.000000000 Z
11
+ date: 2021-12-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Paco is a parser combinator library.
14
14
  email:
@@ -20,14 +20,17 @@ files:
20
20
  - CHANGELOG.md
21
21
  - LICENSE.txt
22
22
  - README.md
23
- - bin/console
24
- - bin/setup
25
23
  - lib/paco.rb
24
+ - lib/paco/callstack.rb
26
25
  - lib/paco/combinators.rb
27
26
  - lib/paco/combinators/char.rb
28
27
  - lib/paco/context.rb
28
+ - lib/paco/index.rb
29
+ - lib/paco/memoizer.rb
29
30
  - lib/paco/parse_error.rb
30
31
  - lib/paco/parser.rb
32
+ - lib/paco/rspec.rb
33
+ - lib/paco/rspec/parse_matcher.rb
31
34
  - lib/paco/version.rb
32
35
  homepage: https://github.com/skryukov/paco
33
36
  licenses:
data/bin/console DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "bundler/setup"
5
- require "paco"
6
-
7
- # You can add fixtures and/or initialization code here to make experimenting
8
- # with your gem easier. You can also use a different console, if you like.
9
-
10
- # (If you use this, don't forget to add pry to your Gemfile!)
11
- # require "pry"
12
- # Pry.start
13
-
14
- require "irb"
15
- IRB.start(__FILE__)
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here