Skip to content

Commit b71500c

Browse files
tyranronilslv
andauthored
Cucumber expressions AST and parser (#1)
Co-authored-by: Ilya Solovyiov <ilya.solovyiov@gmail.com>
1 parent dbe5430 commit b71500c

File tree

8 files changed

+2719
-1
lines changed

8 files changed

+2719
-1
lines changed

.clippy.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ standard-macro-braces = [
66
{ name = "assert", brace = "(" },
77
{ name = "assert_eq", brace = "(" },
88
{ name = "assert_ne", brace = "(" },
9+
{ name = "matches", brace = "(" },
910
{ name = "vec", brace = "[" },
1011
]

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ All user visible changes to `cucumber-expressions` crate will be documented in t
1111

1212
### Added
1313

14-
- ???
14+
- [Cucumber Expressions] AST and parser. ([#1])
1515

16+
[#1]: /../../pull/1
1617

1718

1819

20+
21+
[Cucumber Expressions]: https://github.com/cucumber/cucumber-expressions#readme
1922
[Semantic Versioning 2.0.0]: https://semver.org

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name = "cucumber-expressions"
33
version = "0.1.0-dev"
44
edition = "2021"
55
rust-version = "1.56"
6+
description = "Cucumber Expressions AST and parser."
67
license = "MIT OR Apache-2.0"
78
authors = [
89
"Ilya Solovyiov <ilya.solovyiov@gmail.com>",
@@ -17,3 +18,9 @@ keywords = ["cucumber", "expression", "expressions", "cucumber-expressions"]
1718
include = ["/src/", "/LICENSE-*", "/README.md", "/CHANGELOG.md"]
1819

1920
[dependencies]
21+
derive_more = { version = "0.99.16", features = ["as_ref", "deref", "deref_mut", "display", "error"], default_features = false }
22+
nom = "7.0"
23+
nom_locate = "4.0"
24+
25+
# TODO: Remove once `derive_more` 0.99.17 is released.
26+
syn = "1.0.81"

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,42 @@ This crate provides [AST] and parser of [Cucumber Expressions].
1515

1616

1717

18+
## Grammar
19+
20+
This implementation follows a context-free grammar, [which isn't yet merged][1]. Original grammar is impossible to follow while creating a performant parser, as it consists errors and describes not an exact [Cucumber Expressions] language, but rather some superset language, while being also context-sensitive. In case you've found some inconsistencies between this implementation and the ones in other languages, please file an issue!
21+
22+
[EBNF] spec of the current context-free grammar implemented by this crate:
23+
```ebnf
24+
expression = single-expression*
25+
26+
single-expression = alternation
27+
| optional
28+
| parameter
29+
| text-without-whitespace+
30+
| whitespace
31+
text-without-whitespace = (- (text-to-escape | whitespace))
32+
| ('\', text-to-escape)
33+
text-to-escape = '(' | '{' | '/' | '\'
34+
35+
alternation = single-alternation, (`/`, single-alternation)+
36+
single-alternation = ((text-in-alternative+, optional*)
37+
| (optional+, text-in-alternative+))+
38+
text-in-alternative = (- alternative-to-escape)
39+
| ('\', alternative-to-escape)
40+
alternative-to-escape = ' ' | '(' | '{' | '/' | '\'
41+
42+
optional = '(' text-in-optional+ ')'
43+
text-in-optional = (- optional-to-escape) | ('\', optional-to-escape)
44+
optional-to-escape = '(' | ')' | '{' | '/' | '\'
45+
46+
parameter = '{', name*, '}'
47+
name = (- name-to-escape) | ('\', name-to-escape)
48+
name-to-escape = '{' | '}' | '(' | '/' | '\'
49+
```
50+
51+
52+
53+
1854
## License
1955

2056
This project is licensed under either of
@@ -29,3 +65,6 @@ at your option.
2965

3066
[AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree
3167
[Cucumber Expressions]: https://github.com/cucumber/cucumber-expressions#readme
68+
[EBNF]: https://en.wikipedia.org/wiki/Extended_Backus–Naur_form
69+
70+
[1]: https://github.com/cucumber/cucumber-expressions/issues/41

src/ast.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2021 Brendan Molloy <brendan@bbqsrc.net>,
2+
// Ilya Solovyiov <ilya.solovyiov@gmail.com>,
3+
// Kai Ren <tyranron@gmail.com>
4+
//
5+
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6+
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
8+
// option. This file may not be copied, modified, or distributed
9+
// except according to those terms.
10+
11+
//! [Cucumber Expressions][1] [AST].
12+
//!
13+
//! See details in the [grammar spec][0].
14+
//!
15+
//! [0]: crate#grammar
16+
//! [1]: https://github.com/cucumber/cucumber-expressions#readme
17+
//! [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree
18+
19+
use derive_more::{AsRef, Deref, DerefMut};
20+
use nom::{error::ErrorKind, Err, InputLength};
21+
use nom_locate::LocatedSpan;
22+
23+
use crate::parse;
24+
25+
/// [`str`] along with its location information in the original input.
26+
pub type Spanned<'s> = LocatedSpan<&'s str>;
27+
28+
/// Top-level `expression` defined in the [grammar spec][0].
29+
///
30+
/// See [`parse::expression()`] for the detailed grammar and examples.
31+
///
32+
/// [0]: crate#grammar
33+
#[derive(AsRef, Clone, Debug, Deref, DerefMut, Eq, PartialEq)]
34+
pub struct Expression<Input>(pub Vec<SingleExpression<Input>>);
35+
36+
impl<'s> TryFrom<&'s str> for Expression<Spanned<'s>> {
37+
type Error = parse::Error<Spanned<'s>>;
38+
39+
fn try_from(value: &'s str) -> Result<Self, Self::Error> {
40+
parse::expression(Spanned::new(value))
41+
.map_err(|e| match e {
42+
Err::Error(e) | Err::Failure(e) => e,
43+
Err::Incomplete(n) => parse::Error::Needed(n),
44+
})
45+
.and_then(|(rest, parsed)| {
46+
rest.is_empty()
47+
.then(|| parsed)
48+
.ok_or(parse::Error::Other(rest, ErrorKind::Verify))
49+
})
50+
}
51+
}
52+
53+
impl<'s> Expression<Spanned<'s>> {
54+
/// Parses the given `input` as an [`Expression`].
55+
///
56+
/// # Errors
57+
///
58+
/// See [`parse::Error`] for details.
59+
pub fn parse<I: AsRef<str> + ?Sized>(
60+
input: &'s I,
61+
) -> Result<Self, parse::Error<Spanned<'s>>> {
62+
Self::try_from(input.as_ref())
63+
}
64+
}
65+
66+
/// `single-expression` defined in the [grammar spec][0], representing a single
67+
/// entry of an [`Expression`].
68+
///
69+
/// See [`parse::single_expression()`] for the detailed grammar and examples.
70+
///
71+
/// [0]: crate#grammar
72+
#[derive(Clone, Debug, Eq, PartialEq)]
73+
pub enum SingleExpression<Input> {
74+
/// [`alternation`][0] expression.
75+
///
76+
/// [0]: crate#grammar
77+
Alternation(Alternation<Input>),
78+
79+
/// [`optional`][0] expression.
80+
///
81+
/// [0]: crate#grammar
82+
Optional(Optional<Input>),
83+
84+
/// [`parameter`][0] expression.
85+
///
86+
/// [0]: crate#grammar
87+
Parameter(Parameter<Input>),
88+
89+
/// Text without whitespaces.
90+
Text(Input),
91+
92+
/// Whitespaces are treated as a special case to avoid placing every `text`
93+
/// character in a separate [AST] node, as described in the
94+
/// [grammar spec][0].
95+
///
96+
/// [0]: crate#grammar
97+
/// [AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree
98+
Whitespaces(Input),
99+
}
100+
101+
/// `single-alternation` defined in the [grammar spec][0], representing a
102+
/// building block of an [`Alternation`].
103+
///
104+
/// [0]: crate#grammar
105+
pub type SingleAlternation<Input> = Vec<Alternative<Input>>;
106+
107+
/// `alternation` defined in the [grammar spec][0], allowing to match one of
108+
/// [`SingleAlternation`]s.
109+
///
110+
/// See [`parse::alternation()`] for the detailed grammar and examples.
111+
///
112+
/// [0]: crate#grammar
113+
#[derive(AsRef, Clone, Debug, Deref, DerefMut, Eq, PartialEq)]
114+
pub struct Alternation<Input>(pub Vec<SingleAlternation<Input>>);
115+
116+
impl<Input: InputLength> Alternation<Input> {
117+
/// Returns length of this [`Alternation`]'s span in the `Input`.
118+
pub(crate) fn span_len(&self) -> usize {
119+
self.0
120+
.iter()
121+
.flatten()
122+
.map(|alt| match alt {
123+
Alternative::Text(t) => t.input_len(),
124+
Alternative::Optional(opt) => opt.input_len() + 2,
125+
})
126+
.sum::<usize>()
127+
+ self.len()
128+
- 1
129+
}
130+
131+
/// Indicates whether any of [`SingleAlternation`]s consists only from
132+
/// [`Optional`]s.
133+
pub(crate) fn contains_only_optional(&self) -> bool {
134+
(**self).iter().any(|single_alt| {
135+
single_alt
136+
.iter()
137+
.all(|alt| matches!(alt, Alternative::Optional(_)))
138+
})
139+
}
140+
}
141+
142+
/// `alternative` defined in the [grammar spec][0].
143+
///
144+
/// See [`parse::alternative()`] for the detailed grammar and examples.
145+
///
146+
/// [0]: crate#grammar
147+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148+
pub enum Alternative<Input> {
149+
/// [`optional`][1] expression.
150+
///
151+
/// [1]: crate#grammar
152+
Optional(Optional<Input>),
153+
154+
/// Text.
155+
Text(Input),
156+
}
157+
158+
/// `optional` defined in the [grammar spec][0], allowing to match an optional
159+
/// `Input`.
160+
///
161+
/// See [`parse::optional()`] for the detailed grammar and examples.
162+
///
163+
/// [0]: crate#grammar
164+
#[derive(AsRef, Clone, Copy, Debug, Deref, DerefMut, Eq, PartialEq)]
165+
pub struct Optional<Input>(pub Input);
166+
167+
/// `parameter` defined in the [grammar spec][0], allowing to match some special
168+
/// `Input` described by a [`Parameter`] name.
169+
///
170+
/// See [`parse::parameter()`] for the detailed grammar and examples.
171+
///
172+
/// [0]: crate#grammar
173+
#[derive(AsRef, Clone, Copy, Debug, Deref, DerefMut, Eq, PartialEq)]
174+
pub struct Parameter<Input>(pub Input);

0 commit comments

Comments
 (0)