prettier-plugin-gherkin
Version:
This prettier plugin format your gherkin (`.feature` files) documents.
522 lines (521 loc) • 20.8 kB
JavaScript
import { Parser as GherkinParser, AstBuilder, GherkinClassicTokenMatcher, } from '@cucumber/gherkin';
import { IdGenerator, StepKeywordType, } from '@cucumber/messages';
import { util, doc, } from 'prettier';
import { TypedGherkinDocument, TypedComment, TypedFeature, TypedTag, TypedFeatureChild, TypedScenario, TypedStep, TypedDocString, TypedBackground, TypedExamples, TypedTableRow, TypedTableCell, TypedDataTable, TypedRule, TypedRuleChild, isHasChildren, isHasChild, isWithLocation, } from './GherkinAST/index.js';
const DEFAULT_ESCAPE_BACKSLASH = false;
// taken from https://github.com/cucumber/gherkin-javascript/blob/e25a1be3b21133c7a92eb7735997c6e774406226/src/GherkinClassicTokenMatcher.ts#L11
const LANGUAGE_PATTERN = /^\s*#\s*language\s*:\s*([a-zA-Z\-_]+)\s*$/;
const { hardline, join, indent, line, fill } = doc.builders;
const languages = [
{
extensions: ['.feature'],
name: 'Gherkin',
parsers: ['gherkin'],
},
];
function printHardline() {
// console.log(new Error().stack?.split('\n')[2]);
return hardline;
}
function printTwoHardlines() {
// console.log(new Error().stack?.split('\n')[2]);
return [hardline, hardline];
}
const LINEBREAK_MATCHER = /\r\n|[\r\n\u2028\u2029]/;
/**
* escaple line breaks in a string
*/
function escapeMultilineString(str) {
return str.replace(new RegExp(LINEBREAK_MATCHER, 'g'), '\\n');
}
function assertNodeHasLocation(node) {
if (!('location' in node)) {
throw new Error('no location for node ' + node.constructor.name);
}
}
function findPreviousNode(path, reversePosition) {
const nodePosition = path.stack[path.stack.length - reversePosition];
// @ts-expect-error -- in a TypedStep, we have an array of TypedStep at position reversePosition -1
const siblings = path.stack[path.stack.length - (reversePosition + 1)];
return typeof nodePosition === 'number' && nodePosition > 0
? siblings[nodePosition - 1]
: null;
}
let textColumnWidth = [];
function generateColumnSizes(text) {
let columnSizes = [];
let index = 0;
let currentLine = 1;
while ((index = util.skipEverythingButNewLine(text, index)) !== false) {
columnSizes.push(index + 1);
index++;
currentLine++;
}
// console.log(columnSizes);
textColumnWidth = columnSizes;
}
const gherkinParser = {
parse: (text, options) => {
const uuidFn = IdGenerator.uuid();
const builder = new AstBuilder(uuidFn);
const matcher = new GherkinClassicTokenMatcher(); // or GherkinInMarkdownTokenMatcher()
const parser = new GherkinParser(builder, matcher);
const document = parser.parse(text);
generateColumnSizes(text);
// console.log({
// textColumnWidth,
// // document: JSON.stringify(document, null, 2),
// });
// console.log(
// util.getStringWidth(text),
// util.skipEverythingButNewLine(text, 18)
// );
return new TypedGherkinDocument({
...document,
// comments: [], // igonre comments for now
}, {
escapeBackslashes: options.escapeBackslashes ?? DEFAULT_ESCAPE_BACKSLASH,
});
},
locStart: (node) => {
// return 0;
// if (!(node instanceof TypedComment)) {
// throw new Error('locStart: not a comment');
// }
// console.log('locStart', node);
assertNodeHasLocation(node);
// sum all column size until the current line
let index = node.location.line > 1 ? textColumnWidth[node.location.line - 2] : 0;
index += (node.location.column ?? 1) - 1;
return index;
// if (node instanceof TypedComment) {
// return 0;
// }
// if (node.location) {
// return node.location.line * 1000000 + node.location.column;
// }
// return 0;
// return node.location.column ?? 0;
},
locEnd: (node) => {
// return 0; // TMP ignore comments
// if (!(node instanceof TypedComment)) {
// throw new Error('locEnd: not a comment');
// }
// console.log('locEnd', node, '\n');
if (node instanceof TypedComment) {
return gherkinParser.locStart(node) + node.text.trim().length;
}
// if (!(node instanceof TypedComment)) {
// console.log({ method: 'locEnd', nodeName: node.constructor.name });
// }
if (node instanceof TypedFeature) {
return (gherkinParser.locStart(node) +
node.keyword.length +
node.name.length +
2);
}
// if (!(node instanceof TypedComment)) {
// console.log(node, gherkinParser.locStart(node));
// }
// if (node.location) {
// return node.location.line * 1000000 + node.location.column + 1;
// }
// console.log('locEnd', node);
return gherkinParser.locStart(node) + 1;
// return (node.location.column ?? 0) + node.text.length;
},
astFormat: 'gherkin-ast',
};
function printTags(path, node) {
return [
// @ts-expect-error TODO path should be recognized as an AstPath<GherkinNode & { tags: readonly TypedTag[] }>>
join(printHardline(), path.map(gherkinAstPrinter.print, 'tags')),
node.tags.length > 0 ? printHardline() : '',
];
}
function printNodeHeading(node) {
return `${node.keyword}: ${node.name}`;
}
function printDescription(node, hardlineIfNoDescription) {
if (!node.description) {
return hardlineIfNoDescription ? printHardline() : '';
}
return [printTextBloc(node.description), printHardline()];
}
/**
* This method will try to find the the location of the
*/
function findNodeForCommentInAST(ast, comment) {
const { line, column } = comment.location;
if (isWithLocation(ast) && ast.location.line > line) {
return ast;
}
if (isHasChild(ast)) {
const { child } = ast;
if (child) {
const node = findNodeForCommentInAST(child, comment);
if (node) {
return node;
}
}
}
if (isHasChildren(ast)) {
const { children } = ast;
for (const child of children) {
const node = findNodeForCommentInAST(child, comment);
if (node) {
return node;
}
}
}
return null;
}
function stepNeedsHardline(options, node, previousNode) {
if (!previousNode) {
// do not force hardline for the first step
return false;
}
const currentNode = node;
if (options.forceNewlineBetweenStepBlocks !== true) {
// @ts-expect-error comments are injected by prettier directly
const commentNodes = node.comments ?? [];
const hadHardlineBefore = previousNode &&
currentNode.location.line - commentNodes.length - previousNode.lastLine >=
2;
if (hadHardlineBefore) {
return true;
}
return false;
}
return (node.keywordType &&
[StepKeywordType.CONTEXT, StepKeywordType.ACTION].includes(node.keywordType));
}
function printTextBloc(text, doIndent = false) {
const filledHeading = fill(text
// replace all line break by a hardline
.split('\n')
.map((l) => {
const splittedLine = l
.trim()
// replace all spaces by a line, that will break only if it overflow thanks to the `fill` method
.split(' ')
.map((l, i, a) => {
// if we are on the last element, we do not need to add a line as we will hard a "hardline" just after
if (i === a.length - 1) {
return l;
}
// return the current word and a line after that to break if needed
return [l, line];
})
.flat();
return [...splittedLine, hardline];
})
.flat());
if (!doIndent) {
return filledHeading;
}
return indent(filledHeading);
}
const gherkinAstPrinter = {
printComment: (path, options) => {
const node = path.getValue();
// console.log(node);
if (!(node instanceof TypedComment)) {
throw new Error('printComment: not a comment');
}
const stepNode = path.getParentNode();
const previousNode = findPreviousNode(path, 6);
// if the comment follows a `Given` step, then the comment should have a leading blank line
// but no blank line between the comment and the step
if (stepNode instanceof TypedStep &&
stepNeedsHardline(options, stepNode, previousNode)) {
return [printHardline(), node.text.trim()];
}
return node.text.trim();
},
canAttachComment(node) {
// comments are all in the TypedGherkinDocument.comments array.
// Do not handle comments in the subtree of the AST.
return node instanceof TypedGherkinDocument;
},
isBlockComment(node) {
// // console.log('isBlockComment', node);
return false; // block comments are not supported by gherkin for now. See https://cucumber.io/docs/gherkin/reference/
},
// getCommentChildNodes: (node) => {
// console.log('getCommentChildNodes', node)
// return []
// },
handleComments: {
ownLine: (commentNode, text, options, ast, isLastComment) => {
const node = findNodeForCommentInAST(ast, commentNode);
if (node) {
util.addLeadingComment(node, commentNode);
return true;
}
else {
// we are on the latest lines of the file, and they all contains comments, so attach comment after the ast
const isRemainingLinesComments = text
.split('\n')
.slice(commentNode.location.line)
.filter((row) => row.match(/^\s#/)).length === 0;
if (isRemainingLinesComments) {
util.addTrailingComment(ast, commentNode);
return true;
}
}
// console.log({ ownLine: commentNode, ast });
return false;
},
endOfLine: (commentNode, text, options, ast, isLastComment) => {
const node = findNodeForCommentInAST(ast, commentNode);
if (node) {
util.addLeadingComment(node, commentNode);
return true;
}
// console.log({ ownLine: commentNode, ast });
return false;
},
remaining: (commentNode, text, options, ast, isLastComment) => {
if (ast instanceof TypedGherkinDocument && !ast.feature) {
// special case where the document only contains comments : let's attach the comment to the document directly
util.addLeadingComment(ast, commentNode);
return true;
}
return false;
},
},
print: (path, options, print) => {
const node = path.getValue();
// console.log(node);
if (node instanceof TypedGherkinDocument) {
// console.log(node)
if (node.feature) {
// @ts-expect-error TODO path should be recognized as an AstPath<TypedGherkinDocument>
return [path.call(print, 'feature'), printHardline()];
}
else {
// return empty string if there is no feature (it can contain only comments)
return '';
}
}
else if (node instanceof TypedFeature || node instanceof TypedRule) {
const hasLanguageInSourceFile = !!options.originalText.match(new RegExp(LANGUAGE_PATTERN, 'm'));
return [
node instanceof TypedFeature && hasLanguageInSourceFile
? ['# language: ' + node.language, printHardline()]
: '',
printTags(path, node),
printNodeHeading(node),
indent([
printHardline(),
printDescription(node, true),
// @ts-expect-error TODO path should be recognized as an AstPath<TypedFeature>
join(printTwoHardlines(), path.map(print, 'children')),
]),
];
}
else if (node instanceof TypedFeatureChild ||
node instanceof TypedRuleChild) {
if (node.scenario) {
// @ts-expect-error TODO path should be recognized as an AstPath<TypedFeatureChild>
return path.call(print, 'scenario');
}
else if (node.background) {
// @ts-expect-error TODO path should be recognized as an AstPath<TypedFeatureChild>
return path.call(print, 'background');
}
else if (node instanceof TypedFeatureChild && node.rule) {
// @ts-expect-error TODO path should be recognized as an AstPath<TypedFeatureChild>
return path.call(print, 'rule');
}
else {
throw new Error(`unhandled case where ${node instanceof TypedFeatureChild
? 'TypedFeatureChild'
: 'TypedRuleChild'} has no scenario`);
}
}
else if (node instanceof TypedTag) {
return node.name;
}
else if (node instanceof TypedBackground) {
// console.log(node);
return [
printNodeHeading(node),
node.description || node.steps.length > 0
? indent([
printHardline(),
printDescription(node, false),
// @ts-expect-error TODO path should be recognized as an AstPath<TypedBackground>
join(printHardline(), path.map(print, 'steps')),
])
: '',
];
}
else if (node instanceof TypedScenario) {
// console.log(node);
return [
printTags(path, node),
printNodeHeading(node),
indent([
printHardline(),
printDescription(node, false),
// @ts-expect-error TODO path should be recognized as an AstPath<TypedScenario>
join(printHardline(), path.map(print, 'steps')),
node.steps.length > 0 && node.examples.length > 0
? printTwoHardlines()
: '',
// @ts-expect-error TODO path should be recognized as an AstPath<TypedScenario>
join(printTwoHardlines(), path.map(print, 'examples')),
]),
];
}
else if (node instanceof TypedStep) {
const previousNode = findPreviousNode(path, 2);
return [
// if the step has comment, the hardline will be handled by the comment printer
stepNeedsHardline(options, node, previousNode) &&
// @ts-expect-error comments are injected by prettier directly
!node.comments
? printHardline()
: '',
`${node.keyword}${node.text.trim()}`,
node.docString
? // @ts-expect-error TODO path should be recognized as an AstPath<TypedStep>
indent([printHardline(), path.call(print, 'docString')])
: '',
node.dataTable // @ts-expect-error TODO path should be recognized as an AstPath<TypedStep>
? indent([printHardline(), path.call(print, 'dataTable')])
: '',
];
}
else if (node instanceof TypedDocString) {
// console.log({ node });
// if the content contains the delimiter, the parser will unescape it, so we need to escape it again
const escapeDelimiter = (content) => {
return content.replace(new RegExp(`(${node.delimiter})`, 'g'), (match) => {
return match
.split('')
.map((c) => `\\${c}`)
.join('');
});
};
return join(printHardline(), [
[node.delimiter, node.mediaType || ''],
// split the string on newlines to preserve the indentation
...escapeDelimiter(node.content).split('\n'),
node.delimiter,
]);
}
else if (node instanceof TypedExamples) {
// console.log({ node, columnSizes: node.columnSizes });
return [
printTags(path, node),
printNodeHeading(node),
indent([
printHardline(),
printDescription(node, false),
join(printHardline(), [
// @ts-expect-error TODO path should be recognized as an AstPath<TypedExamples>
node.tableHeader && path.call(print, 'tableHeader'),
// @ts-expect-error TODO path should be recognized as an AstPath<TypedExamples>
...path.map(print, 'tableBody'),
].filter((d) => !!d)),
]),
];
}
else if (node instanceof TypedDataTable) {
// console.log({ node, columnSizes: node.columnSizes });
return join(printHardline(), [
// @ts-expect-error TODO path should be recognized as an AstPath<TypedDataTable>
...path.map(print, 'rows'),
]);
}
else if (node instanceof TypedTableRow) {
// console.log(node);
return [
'| ',
// @ts-expect-error TODO path should be recognized as an AstPath<TypedTableRow>
join(' | ', path.map(print, 'cells')),
' |',
];
}
else if (node instanceof TypedTableCell) {
// console.log(node);
return escapeMultilineString(node.value.padEnd(node.displaySize));
}
else {
console.error('Unhandled node type', node);
return '';
}
},
embed(path, options) {
const node = path.getValue();
if (node instanceof TypedDocString) {
const { content, mediaType } = node;
return async (textToDoc) => {
let doc = null;
if (mediaType) {
// try applying the prettier parser for the media type
doc = await textToDoc(content, {
...options,
parser: mediaType,
}).catch(() => null);
// if the parser failed for xml, try with the html parser
if (!doc && mediaType === 'xml') {
doc = await textToDoc(content, {
...options,
parser: 'html',
}).catch(() => null);
}
}
// try applying the json parser
if (!doc) {
doc = await textToDoc(content, { ...options, parser: 'json' }).catch(() => null);
}
if (doc) {
return [
node.delimiter,
mediaType ?? '',
printHardline(),
doc,
printHardline(),
node.delimiter,
];
}
};
}
return null;
},
};
const plugin = {
languages,
parsers: {
gherkin: gherkinParser,
},
printers: {
'gherkin-ast': gherkinAstPrinter,
},
defaultOptions: {
printWidth: 120,
tabWidth: 2,
},
options: {
escapeBackslashes: {
type: 'boolean',
default: DEFAULT_ESCAPE_BACKSLASH,
description: 'Escape backslashes in strings',
oppositeDescription: 'Do not escape backslashes in strings',
category: 'Format',
},
forceNewlineBetweenStepBlocks: {
type: 'boolean',
default: false,
description: 'Force new line between Context and Action blocks',
oppositeDescription: 'Do not force hardline between Context and Action blocks',
category: 'Format',
},
},
};
export default plugin;