Skip to content

Extract compiler #108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions javascript/src/AbstractCompiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Node, NodeType } from './Ast.js'
import CucumberExpressionError from './CucumberExpressionError.js'
import {
createAlternativeMayNotBeEmpty,
createAlternativeMayNotExclusivelyContainOptionals,
createOptionalIsNotAllowedInOptional,
createOptionalMayNotBeEmpty,
createParameterIsNotAllowedInOptional,
createUndefinedParameterType,
} from './Errors.js'
import ParameterType from './ParameterType.js'
import ParameterTypeRegistry from './ParameterTypeRegistry.js'

export default abstract class AbstractCompiler<T> {
constructor(
private readonly expression: string,
private readonly parameterTypeRegistry: ParameterTypeRegistry
) {}

protected abstract produceText(expression: string): T
protected abstract produceOptional(segments: T[]): T
protected abstract produceAlternation(segments: T[]): T
protected abstract produceAlternative(segments: T[]): T
protected abstract produceParameter(parameterType: ParameterType<unknown>): T
protected abstract produceExpression(segments: T[]): T

public compile(node: Node): T {
switch (node.type) {
case NodeType.text:
return this.produceText(node.text())
case NodeType.optional:
return this.compileOptional(node)
case NodeType.alternation:
return this.compileAlternation(node)
case NodeType.alternative:
return this.compileAlternative(node)
case NodeType.parameter:
return this.compileParameter(node)
case NodeType.expression:
return this.compileExpression(node)
default:
// Can't happen as long as the switch case is exhaustive
throw new Error(node.type)
}
}

private compileOptional(node: Node): T {
this.assertNoParameters(node, (astNode) =>
createParameterIsNotAllowedInOptional(astNode, this.expression)
)
this.assertNoOptionals(node, (astNode) =>
createOptionalIsNotAllowedInOptional(astNode, this.expression)
)
this.assertNotEmpty(node, (astNode) => createOptionalMayNotBeEmpty(astNode, this.expression))

const segments = (node.nodes || []).map((node) => this.compile(node))
return this.produceOptional(segments)
}

private compileAlternation(node: Node) {
// Make sure the alternative parts aren't empty and don't contain parameter types
for (const alternative of node.nodes || []) {
if (!alternative.nodes || alternative.nodes.length == 0) {
throw createAlternativeMayNotBeEmpty(alternative, this.expression)
}
this.assertNotEmpty(alternative, (astNode) =>
createAlternativeMayNotExclusivelyContainOptionals(astNode, this.expression)
)
}
const segments = (node.nodes || []).map((node) => this.compile(node))
return this.produceAlternation(segments)
}

private compileAlternative(node: Node) {
const segments = (node.nodes || []).map((lastNode) => this.compile(lastNode))
return this.produceAlternative(segments)
}

private compileParameter(node: Node) {
const name = node.text()
const parameterType = this.parameterTypeRegistry.lookupByTypeName(name)
if (!parameterType) {
throw createUndefinedParameterType(node, this.expression, name)
}
return this.produceParameter(parameterType)
}

private compileExpression(node: Node) {
const segments = (node.nodes || []).map((node) => this.compile(node))
return this.produceExpression(segments)
}

private assertNotEmpty(
node: Node,
createNodeWasNotEmptyException: (astNode: Node) => CucumberExpressionError
) {
const textNodes = (node.nodes || []).filter((astNode) => NodeType.text == astNode.type)

if (textNodes.length == 0) {
throw createNodeWasNotEmptyException(node)
}
}

private assertNoParameters(
node: Node,
createNodeContainedAParameterError: (astNode: Node) => CucumberExpressionError
) {
const parameterNodes = (node.nodes || []).filter(
(astNode) => NodeType.parameter == astNode.type
)
if (parameterNodes.length > 0) {
throw createNodeContainedAParameterError(parameterNodes[0])
}
}

private assertNoOptionals(
node: Node,
createNodeContainedAnOptionalError: (astNode: Node) => CucumberExpressionError
) {
const parameterNodes = (node.nodes || []).filter((astNode) => NodeType.optional == astNode.type)
if (parameterNodes.length > 0) {
throw createNodeContainedAnOptionalError(parameterNodes[0])
}
}
}
126 changes: 6 additions & 120 deletions javascript/src/CucumberExpression.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import Argument from './Argument.js'
import { Node, NodeType } from './Ast.js'
import CucumberExpressionError from './CucumberExpressionError.js'
import { Node } from './Ast.js'
import CucumberExpressionParser from './CucumberExpressionParser.js'
import {
createAlternativeMayNotBeEmpty,
createAlternativeMayNotExclusivelyContainOptionals,
createOptionalIsNotAllowedInOptional,
createOptionalMayNotBeEmpty,
createParameterIsNotAllowedInOptional,
createUndefinedParameterType,
} from './Errors.js'
import ParameterType from './ParameterType.js'
import ParameterTypeRegistry from './ParameterTypeRegistry.js'
import RegExpCompiler from './RegExpCompiler.js'
import TreeRegexp from './TreeRegexp.js'
import { Expression } from './types.js'

const ESCAPE_PATTERN = () => /([\\^[({$.|?*+})\]])/g

export default class CucumberExpression implements Expression {
private readonly parameterTypes: Array<ParameterType<unknown>> = []
private readonly treeRegexp: TreeRegexp
public readonly ast: Node

/**
* @param expression
Expand All @@ -30,117 +21,12 @@ export default class CucumberExpression implements Expression {
private readonly parameterTypeRegistry: ParameterTypeRegistry
) {
const parser = new CucumberExpressionParser()
const ast = parser.parse(expression)
const pattern = this.rewriteToRegex(ast)
this.ast = parser.parse(expression)
const compiler = new RegExpCompiler(expression, parameterTypeRegistry, this.parameterTypes)
const pattern = compiler.compile(this.ast)
this.treeRegexp = new TreeRegexp(pattern)
}

private rewriteToRegex(node: Node): string {
switch (node.type) {
case NodeType.text:
return CucumberExpression.escapeRegex(node.text())
case NodeType.optional:
return this.rewriteOptional(node)
case NodeType.alternation:
return this.rewriteAlternation(node)
case NodeType.alternative:
return this.rewriteAlternative(node)
case NodeType.parameter:
return this.rewriteParameter(node)
case NodeType.expression:
return this.rewriteExpression(node)
default:
// Can't happen as long as the switch case is exhaustive
throw new Error(node.type)
}
}

private static escapeRegex(expression: string) {
return expression.replace(ESCAPE_PATTERN(), '\\$1')
}

private rewriteOptional(node: Node): string {
this.assertNoParameters(node, (astNode) =>
createParameterIsNotAllowedInOptional(astNode, this.expression)
)
this.assertNoOptionals(node, (astNode) =>
createOptionalIsNotAllowedInOptional(astNode, this.expression)
)
this.assertNotEmpty(node, (astNode) => createOptionalMayNotBeEmpty(astNode, this.expression))
const regex = (node.nodes || []).map((node) => this.rewriteToRegex(node)).join('')
return `(?:${regex})?`
}

private rewriteAlternation(node: Node) {
// Make sure the alternative parts aren't empty and don't contain parameter types
;(node.nodes || []).forEach((alternative) => {
if (!alternative.nodes || alternative.nodes.length == 0) {
throw createAlternativeMayNotBeEmpty(alternative, this.expression)
}
this.assertNotEmpty(alternative, (astNode) =>
createAlternativeMayNotExclusivelyContainOptionals(astNode, this.expression)
)
})
const regex = (node.nodes || []).map((node) => this.rewriteToRegex(node)).join('|')
return `(?:${regex})`
}

private rewriteAlternative(node: Node) {
return (node.nodes || []).map((lastNode) => this.rewriteToRegex(lastNode)).join('')
}

private rewriteParameter(node: Node) {
const name = node.text()
const parameterType = this.parameterTypeRegistry.lookupByTypeName(name)
if (!parameterType) {
throw createUndefinedParameterType(node, this.expression, name)
}
this.parameterTypes.push(parameterType)
const regexps = parameterType.regexpStrings
if (regexps.length == 1) {
return `(${regexps[0]})`
}
return `((?:${regexps.join(')|(?:')}))`
}

private rewriteExpression(node: Node) {
const regex = (node.nodes || []).map((node) => this.rewriteToRegex(node)).join('')
return `^${regex}$`
}

private assertNotEmpty(
node: Node,
createNodeWasNotEmptyException: (astNode: Node) => CucumberExpressionError
) {
const textNodes = (node.nodes || []).filter((astNode) => NodeType.text == astNode.type)

if (textNodes.length == 0) {
throw createNodeWasNotEmptyException(node)
}
}

private assertNoParameters(
node: Node,
createNodeContainedAParameterError: (astNode: Node) => CucumberExpressionError
) {
const parameterNodes = (node.nodes || []).filter(
(astNode) => NodeType.parameter == astNode.type
)
if (parameterNodes.length > 0) {
throw createNodeContainedAParameterError(parameterNodes[0])
}
}

private assertNoOptionals(
node: Node,
createNodeContainedAnOptionalError: (astNode: Node) => CucumberExpressionError
) {
const parameterNodes = (node.nodes || []).filter((astNode) => NodeType.optional == astNode.type)
if (parameterNodes.length > 0) {
throw createNodeContainedAnOptionalError(parameterNodes[0])
}
}

public match(text: string): readonly Argument[] | null {
const group = this.treeRegexp.match(text)
if (!group) {
Expand Down
2 changes: 1 addition & 1 deletion javascript/src/ParameterType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default class ParameterType<T> {
constructor(
public readonly name: string | undefined,
regexps: readonly RegExp[] | readonly string[] | RegExp | string,
private readonly type: unknown,
public readonly type: unknown,
transform: (...match: string[]) => T,
public readonly useForSnippets: boolean,
public readonly preferForRegexpMatch: boolean
Expand Down
47 changes: 47 additions & 0 deletions javascript/src/RegExpCompiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import AbstractCompiler from './AbstractCompiler.js'
import ParameterType from './ParameterType.js'
import ParameterTypeRegistry from './ParameterTypeRegistry'

const ESCAPE_PATTERN = () => /([\\^[({$.|?*+})\]])/g

export default class RegExpCompiler extends AbstractCompiler<string> {
constructor(
expression: string,
parameterTypeRegistry: ParameterTypeRegistry,
private readonly parameterTypes: Array<ParameterType<unknown>>
) {
super(expression, parameterTypeRegistry)
}

protected produceText(expression: string) {
return expression.replace(ESCAPE_PATTERN(), '\\$1')
}

protected produceOptional(segments: string[]): string {
const regex = segments.join('')
return `(?:${regex})?`
}

protected produceAlternation(segments: string[]): string {
const regex = segments.join('|')
return `(?:${regex})`
}

protected produceAlternative(segments: string[]): string {
return segments.join('')
}

protected produceParameter(parameterType: ParameterType<unknown>): string {
this.parameterTypes.push(parameterType)
const regexps = parameterType.regexpStrings
if (regexps.length == 1) {
return `(${regexps[0]})`
}
return `((?:${regexps.join(')|(?:')}))`
}

protected produceExpression(segments: string[]): string {
const regex = segments.join('')
return `^${regex}$`
}
}
2 changes: 2 additions & 0 deletions javascript/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AbstractCompiler from './AbstractCompiler.js'
import Argument from './Argument.js'
import CucumberExpression from './CucumberExpression.js'
import CucumberExpressionGenerator from './CucumberExpressionGenerator.js'
Expand All @@ -10,6 +11,7 @@ import RegularExpression from './RegularExpression.js'
import { Expression } from './types.js'

export {
AbstractCompiler,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could also export RegExpCompiler? That may be useful sometime?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only used internally, so I'd rather not. By exposing as little as possible we minimise the API surface to maintain.

Argument,
CucumberExpression,
CucumberExpressionGenerator,
Expand Down