Skip to content

Commit 0c3fad0

Browse files
committed
feat(gherkin): added possibility to register custom flavors
1 parent 4a91cfb commit 0c3fad0

10 files changed

+346
-44
lines changed

javascript/src/GherkinInMarkdownTokenMatcher.ts

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import ITokenMatcher from './ITokenMatcher'
22
import Dialect from './Dialect'
3-
import { Token, TokenType } from './Parser'
3+
import {Token, TokenType} from './Parser'
44
import DIALECTS from './gherkin-languages.json'
5-
import { Item } from './IToken'
5+
import {Item} from './IToken'
66
import * as messages from '@cucumber/messages'
7-
import { NoSuchLanguageException } from './Errors'
7+
import {NoSuchLanguageException} from './Errors'
8+
import {KeywordPrefixes} from "./flavors/KeywordPrefixes";
89

9-
const DIALECT_DICT: { [key: string]: Dialect } = DIALECTS
10-
const DEFAULT_DOC_STRING_SEPARATOR = /^(```[`]*)(.*)/
10+
11+
export const DIALECT_DICT: { [key: string]: Dialect } = DIALECTS
12+
export const DEFAULT_DOC_STRING_SEPARATOR = /^(```[`]*)(.*)/
1113

1214
function addKeywordTypeMappings(h: { [key: string]: messages.StepKeywordType[] }, keywords: readonly string[], keywordType: messages.StepKeywordType) {
1315
for (const k of keywords) {
@@ -19,17 +21,27 @@ function addKeywordTypeMappings(h: { [key: string]: messages.StepKeywordType[] }
1921
}
2022

2123
export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<TokenType> {
22-
private dialect: Dialect
23-
private dialectName: string
24-
private readonly nonStarStepKeywords: string[]
24+
dialect: Dialect
25+
dialectName: string
26+
readonly nonStarStepKeywords: string[]
2527
private readonly stepRegexp: RegExp
2628
private readonly headerRegexp: RegExp
2729
private activeDocStringSeparator: RegExp
2830
private indentToRemove: number
29-
private matchedFeatureLine: boolean
31+
matchedFeatureLine: boolean
32+
private prefixes: KeywordPrefixes = {
33+
// https://spec.commonmark.org/0.29/#bullet-list-marker
34+
BULLET: '^(\\s*[*+-]\\s*)',
35+
HEADER: '^(#{1,6}\\s)',
36+
}
37+
private readonly docStringSeparator = DEFAULT_DOC_STRING_SEPARATOR;
38+
3039
private keywordTypesMap: { [key: string]: messages.StepKeywordType[] }
3140

32-
constructor(private readonly defaultDialectName: string = 'en') {
41+
constructor(private readonly defaultDialectName: string = 'en', prefixes?: KeywordPrefixes, docStringSeparator?: RegExp) {
42+
prefixes ? this.prefixes = prefixes : null;
43+
docStringSeparator ? this.docStringSeparator = docStringSeparator : this.docStringSeparator = DEFAULT_DOC_STRING_SEPARATOR;
44+
3345
this.dialect = DIALECT_DICT[defaultDialectName]
3446
this.nonStarStepKeywords = []
3547
.concat(this.dialect.given)
@@ -41,7 +53,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
4153
this.initializeKeywordTypes()
4254

4355
this.stepRegexp = new RegExp(
44-
`${KeywordPrefix.BULLET}(${this.nonStarStepKeywords.map(escapeRegExp).join('|')})`
56+
`${this.prefixes.BULLET}(${this.nonStarStepKeywords.map(escapeRegExp).join('|')})`
4557
)
4658

4759
const headerKeywords = []
@@ -54,7 +66,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
5466
.filter((value, index, self) => self.indexOf(value) === index)
5567

5668
this.headerRegexp = new RegExp(
57-
`${KeywordPrefix.HEADER}(${headerKeywords.map(escapeRegExp).join('|')})`
69+
`${this.prefixes.HEADER}(${headerKeywords.map(escapeRegExp).join('|')})`
5870
)
5971

6072
this.reset()
@@ -140,11 +152,11 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
140152
const [, newSeparator, mediaType] = match || []
141153
let result = false
142154
if (newSeparator) {
143-
if (this.activeDocStringSeparator === DEFAULT_DOC_STRING_SEPARATOR) {
155+
if (this.activeDocStringSeparator === this.docStringSeparator) {
144156
this.activeDocStringSeparator = new RegExp(`^(${newSeparator})$`)
145157
this.indentToRemove = token.line.indent
146158
} else {
147-
this.activeDocStringSeparator = DEFAULT_DOC_STRING_SEPARATOR
159+
this.activeDocStringSeparator = this.docStringSeparator
148160
}
149161

150162
token.matchedKeyword = newSeparator
@@ -171,7 +183,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
171183
}
172184
// We first try to match "# Feature: blah"
173185
let result = this.matchTitleLine(
174-
KeywordPrefix.HEADER,
186+
this.prefixes.HEADER,
175187
this.dialect.feature,
176188
':',
177189
token,
@@ -191,7 +203,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
191203

192204
match_BackgroundLine(token: Token): boolean {
193205
return this.matchTitleLine(
194-
KeywordPrefix.HEADER,
206+
this.prefixes.HEADER,
195207
this.dialect.background,
196208
':',
197209
token,
@@ -201,7 +213,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
201213

202214
match_RuleLine(token: Token): boolean {
203215
return this.matchTitleLine(
204-
KeywordPrefix.HEADER,
216+
this.prefixes.HEADER,
205217
this.dialect.rule,
206218
':',
207219
token,
@@ -212,14 +224,14 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
212224
match_ScenarioLine(token: Token): boolean {
213225
return (
214226
this.matchTitleLine(
215-
KeywordPrefix.HEADER,
227+
this.prefixes.HEADER,
216228
this.dialect.scenario,
217229
':',
218230
token,
219231
TokenType.ScenarioLine
220232
) ||
221233
this.matchTitleLine(
222-
KeywordPrefix.HEADER,
234+
this.prefixes.HEADER,
223235
this.dialect.scenarioOutline,
224236
':',
225237
token,
@@ -230,7 +242,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
230242

231243
match_ExamplesLine(token: Token): boolean {
232244
return this.matchTitleLine(
233-
KeywordPrefix.HEADER,
245+
this.prefixes.HEADER,
234246
this.dialect.examples,
235247
':',
236248
token,
@@ -240,7 +252,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
240252

241253
match_StepLine(token: Token): boolean {
242254
return this.matchTitleLine(
243-
KeywordPrefix.BULLET,
255+
this.prefixes.BULLET,
244256
this.nonStarStepKeywords,
245257
'',
246258
token,
@@ -249,7 +261,7 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
249261
}
250262

251263
matchTitleLine(
252-
prefix: KeywordPrefix,
264+
prefix: string,
253265
keywords: readonly string[],
254266
keywordSuffix: ':' | '',
255267
token: Token,
@@ -333,16 +345,10 @@ export default class GherkinInMarkdownTokenMatcher implements ITokenMatcher<Toke
333345
if (this.dialectName !== this.defaultDialectName) {
334346
this.changeDialect(this.defaultDialectName)
335347
}
336-
this.activeDocStringSeparator = DEFAULT_DOC_STRING_SEPARATOR
348+
this.activeDocStringSeparator = this.docStringSeparator;
337349
}
338350
}
339351

340-
enum KeywordPrefix {
341-
// https://spec.commonmark.org/0.29/#bullet-list-marker
342-
BULLET = '^(\\s*[*+-]\\s*)',
343-
HEADER = '^(#{1,6}\\s)',
344-
}
345-
346352
// https://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript
347353
function escapeRegExp(text: string) {
348354
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import ITokenMatcher from "../ITokenMatcher";
2+
import {TokenType} from "../Parser";
3+
import GherkinFlavor from "./GherkinFlavor";
4+
5+
/**
6+
* This class provides a way to extend the gherkin language by adding flavor implementations such as
7+
* AsciiDoc flavor or Markdown flavor.
8+
*
9+
*/
10+
export default class CustomFlavorRegistry {
11+
private flavors: Array<GherkinFlavor>;
12+
13+
constructor() {
14+
this.flavors = new Array<GherkinFlavor>();
15+
}
16+
17+
public registerFlavor(name: string, fileExtension: string, tokenMatcher: ITokenMatcher<TokenType>) {
18+
this.flavors.push(new GherkinFlavor(name, fileExtension, tokenMatcher));
19+
}
20+
21+
mediaTypeFor(uri: string): string {
22+
const flavor = this.flavors.find(flavor => uri.endsWith(flavor.fileExtension))
23+
return flavor.mediaType;
24+
}
25+
26+
tokenMatcherFor(sourceMediaType: string): ITokenMatcher<TokenType> {
27+
const flavor = this.flavors.find(flavor => flavor.mediaType === sourceMediaType);
28+
return flavor.tokenMatcher;
29+
}
30+
31+
private static instance: CustomFlavorRegistry;
32+
public static getInstance() {
33+
if(!this.instance) {
34+
this.instance = new CustomFlavorRegistry();
35+
}
36+
37+
return this.instance;
38+
}
39+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import ITokenMatcher from "../ITokenMatcher";
2+
import {TokenType} from "../Parser";
3+
4+
export default class GherkinFlavor {
5+
6+
constructor(public name: string, public fileExtension: string, public tokenMatcher: ITokenMatcher<TokenType>) {
7+
8+
}
9+
10+
get mediaType(): string {
11+
return `text/x.cucumber.gherkin+${this.name}`;
12+
}
13+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type KeywordPrefixes = {
2+
BULLET: string,
3+
HEADER: string,
4+
}

javascript/src/generateMessages.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,29 @@ import IGherkinOptions from './IGherkinOptions'
77
import makeSourceEnvelope from './makeSourceEnvelope'
88
import ITokenMatcher from './ITokenMatcher'
99
import GherkinInMarkdownTokenMatcher from './GherkinInMarkdownTokenMatcher'
10+
import CustomFlavorRegistry from "./flavors/CustomFlavorRegistry";
1011

1112
export default function generateMessages(
1213
data: string,
1314
uri: string,
14-
mediaType: messages.SourceMediaType,
15+
mediaType: string,
1516
options: IGherkinOptions
1617
): readonly messages.Envelope[] {
18+
1719
let tokenMatcher: ITokenMatcher<TokenType>
18-
switch (mediaType) {
19-
case messages.SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN:
20-
tokenMatcher = new GherkinClassicTokenMatcher(options.defaultDialect)
21-
break
22-
case messages.SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_MARKDOWN:
23-
tokenMatcher = new GherkinInMarkdownTokenMatcher(options.defaultDialect)
24-
break
25-
default:
20+
const customFlavorsRegistry = CustomFlavorRegistry.getInstance();
21+
22+
if (mediaType === 'text/x.cucumber.gherkin+plain') {
23+
tokenMatcher = new GherkinClassicTokenMatcher(options.defaultDialect)
24+
} else if (mediaType === 'text/x.cucumber.gherkin+markdown') {
25+
tokenMatcher = new GherkinInMarkdownTokenMatcher(options.defaultDialect)
26+
} else {
27+
tokenMatcher = customFlavorsRegistry.tokenMatcherFor(mediaType)
28+
if(!tokenMatcher)
2629
throw new Error(`Unsupported media type: ${mediaType}`)
2730
}
2831

32+
2933
const result = []
3034

3135
try {

javascript/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import compile from './pickles/compile'
1010
import DIALECTS from './gherkin-languages.json'
1111
import GherkinClassicTokenMatcher from './GherkinClassicTokenMatcher'
1212
import GherkinInMarkdownTokenMatcher from './GherkinInMarkdownTokenMatcher'
13+
import CustomFlavorRegistry from './flavors/CustomFlavorRegistry'
1314

1415
const dialects = DIALECTS as Readonly<{ [key: string]: Dialect }>
1516

@@ -25,5 +26,6 @@ export {
2526
Errors,
2627
GherkinClassicTokenMatcher,
2728
GherkinInMarkdownTokenMatcher,
29+
CustomFlavorRegistry,
2830
compile,
2931
}

javascript/src/makeSourceEnvelope.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import * as messages from '@cucumber/messages'
2+
import CustomFlavorRegistry from "./flavors/CustomFlavorRegistry";
23

34
export default function makeSourceEnvelope(data: string, uri: string): messages.Envelope {
4-
let mediaType: messages.SourceMediaType
5+
let mediaType: string
6+
let customFlavorsRegistry = CustomFlavorRegistry.getInstance();
7+
58
if (uri.endsWith('.feature')) {
6-
mediaType = messages.SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN
9+
mediaType = 'text/x.cucumber.gherkin+plain'
710
} else if (uri.endsWith('.md')) {
8-
mediaType = messages.SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_MARKDOWN
11+
mediaType = 'text/x.cucumber.gherkin+markdown'
12+
} else {
13+
mediaType = customFlavorsRegistry.mediaTypeFor(uri);
914
}
1015
if (!mediaType) throw new Error(`The uri (${uri}) must end with .feature or .md`)
1116
return {

0 commit comments

Comments
 (0)