JamieMason/expect-more

View on GitHub
scripts/generate-matchers/index.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { resolve } from 'path';
import ts from 'typescript';
import { generateJasmineIndex } from './jasmine-index';
import { generateJasmineMatcher } from './jasmine-matcher';
import { generateJasmineMemberMatcher } from './jasmine-member-matcher';
import { generateJasmineMatcherTest } from './jasmine-matcher-test';
import { generateJasmineMemberMatcherTest } from './jasmine-member-matcher-test';
import { generateJestIndex } from './jest-index';
import { generateJestMatcher } from './jest-matcher';
import { generateJestMatcherTest } from './jest-matcher-test';
import { allFilePaths, expectMoreJasminePath, expectMoreJestPath, rootFilePaths } from './paths';

export interface FileJsDoc {
  description: string;
  matcherMessage: string;
  matcherName: string;
  matcherNotMessage: string;
  memberMatcherName: string;
  params: Array<{
    name: string;
    exampleValue: string;
  }>;
}

export interface FileMeta {
  filePath: string;
  inputs: string[];
  inputsWithoutTypes: string[];
  jasmineMatcherPath: string;
  jasmineMatcherTestPath: string;
  jasmineMemberMatcherPath: string;
  jasmineMemberMatcherTestPath: string;
  jestMatcherPath: string;
  jestMatcherTestPath: string;
  jestMemberMatcherPath: string;
  jestMemberMatcherTestPath: string;
  jsDoc: FileJsDoc;
  matcherInputs: string[];
  matcherInputsWithoutTypes: string[];
  memberMatcherInputs: string[];
  memberMatcherInputsWithoutTypes: string[];
  name: string;
  signature: string;
}

export const camelToKebab = (camel: string): string =>
  camel.replace(/[A-Z]/g, (a) => `-${a.toLowerCase()}`);

const isAllowableJasmineMatcher = ({ jsDoc }) =>
  jsDoc.matcherName.search(
    /^(toBeGreaterThanOrEqualTo|toBeLessThanOrEqualTo|toBeNear|toBeNull|toBeUndefined|toHaveMember|toThrowAnyError|toThrowErrorOfType|toBeTrue|toBeFalse)$/,
  ) === -1;

const isAllowableJasmineMemberMatcher = ({ jsDoc }) =>
  jsDoc.memberMatcherName.search(
    /^(toHaveMethodThrowingAnyError|toHaveMethodThrowingErrorOfType|toHaveNestedMember)$/,
  ) === -1;

const isAllowableJestMatcher = ({ jsDoc }) =>
  jsDoc.matcherName.search(
    /^(toBeGreaterThanOrEqualTo|toBeLessThanOrEqualTo|toBeNear|toBeNull|toBeUndefined|toHaveMember|toThrowAnyError|toThrowErrorOfType)$/,
  ) === -1;

const isExportedVariable = ({ kind, modifiers }) =>
  kind === ts.SyntaxKind.VariableStatement &&
  modifiers &&
  modifiers[0] &&
  modifiers[0].kind === ts.SyntaxKind.ExportKeyword;

const options = {};
const program = ts.createProgram(allFilePaths, options);

const isCurriedFn = ({ initializer }) =>
  initializer &&
  initializer.expression &&
  initializer.expression.escapedText &&
  initializer.expression.escapedText.startsWith('curry');

const isUnaryfunction = ({ initializer }) =>
  initializer && initializer.kind === ts.SyntaxKind.ArrowFunction;

const isHasTypeFunction = ({ initializer }) =>
  initializer && initializer.expression && initializer.expression.text === 'hasType';

const getJasmineMatcherPath = (matcherName) =>
  resolve(expectMoreJasminePath, `./${camelToKebab(matcherName)}.ts`);

const getJasmineMatcherTestPath = (matcherName) =>
  resolve(expectMoreJasminePath, `../test/${camelToKebab(matcherName)}.spec.ts`);

const getJestMatcherPath = (matcherName) =>
  resolve(expectMoreJestPath, `./${camelToKebab(matcherName)}.ts`);

const getJestMatcherTestPath = (matcherName) =>
  resolve(expectMoreJestPath, `../test/${camelToKebab(matcherName)}.spec.ts`);

const unwrap = (string) => (string ? string.replace(/\n/g, ' ') : '');

const getMetadata = (filePath): FileMeta => {
  const sourceFile = program.getSourceFile(filePath);
  const result: FileMeta = {
    filePath,
    inputs: [],
    inputsWithoutTypes: [],
    jasmineMatcherPath: '',
    jasmineMatcherTestPath: '',
    jasmineMemberMatcherPath: '',
    jasmineMemberMatcherTestPath: '',
    jestMatcherPath: '',
    jestMatcherTestPath: '',
    jestMemberMatcherPath: '',
    jestMemberMatcherTestPath: '',
    jsDoc: {
      description: '',
      matcherMessage: '',
      matcherName: '',
      matcherNotMessage: '',
      memberMatcherName: '',
      params: [],
    },
    matcherInputs: [],
    matcherInputsWithoutTypes: [],
    memberMatcherInputs: [],
    memberMatcherInputsWithoutTypes: [],
    name: '',
    signature: '',
  };

  const getSignature = (variableDeclaration) => {
    if (isCurriedFn(variableDeclaration)) {
      const fn = variableDeclaration.initializer.arguments[0];
      const inTypes = fn.parameters.map((param) => param.getText(sourceFile));
      const outType = fn.type.getText(sourceFile);
      const signature = `(${inTypes.join(', ')}) => ${outType}`;
      return signature;
    } else if (isUnaryfunction(variableDeclaration)) {
      const init = variableDeclaration.initializer;
      const inTypes = init.parameters.map((param) => param.getText(sourceFile));
      const outType = init.type.getText(sourceFile);
      const signature = `(${inTypes.join(', ')}) => ${outType}`;
      return signature;
    } else if (isHasTypeFunction(variableDeclaration)) {
      const init = variableDeclaration.initializer;
      const outType = init.typeArguments[0].getText(sourceFile);
      const signature = `(value: unknown) => value is ${outType}`;
      return signature;
    } else {
      throw new Error(`Unrecognised Signature at ${filePath}`);
    }
  };

  const getArgumentTypes = (variableDeclaration) => {
    if (isCurriedFn(variableDeclaration)) {
      const fn = variableDeclaration.initializer.arguments[0];
      const signature = fn.parameters.map((param) => param.getText(sourceFile));
      return signature;
    } else if (isUnaryfunction(variableDeclaration)) {
      const signature = variableDeclaration.initializer.parameters.map((param) =>
        param.getText(sourceFile),
      );
      return signature;
    } else if (isHasTypeFunction(variableDeclaration)) {
      return ['value: unknown'];
    } else {
      throw new Error(`Unrecognised Signature at ${filePath}`);
    }
  };

  const visitNode = (node) => {
    if (node.jsDoc) {
      const [jsDocComment] = node.jsDoc;
      result.jsDoc.description = unwrap(jsDocComment.comment);
      jsDocComment.tags.forEach(({ comment, name, tagName }) => {
        if (tagName.text === 'param') {
          result.jsDoc.params.push({ name: name.text, exampleValue: unwrap(comment) });
        } else {
          result.jsDoc[tagName.text] = unwrap(comment);
        }
      });
      result.jsDoc.matcherMessage = result.jsDoc.matcherMessage.replace(/`/g, '\\`');
      result.jsDoc.matcherNotMessage = result.jsDoc.matcherNotMessage.replace(/`/g, '\\`');
    }
    if (isExportedVariable(node)) {
      const [variableDeclaration] = node.declarationList.declarations;
      result.name = variableDeclaration.name.text;
      result.signature = getSignature(variableDeclaration);
      result.inputs = getArgumentTypes(variableDeclaration);
      result.jasmineMatcherPath = getJasmineMatcherPath(result.jsDoc.matcherName);
      result.jasmineMatcherTestPath = getJasmineMatcherTestPath(result.jsDoc.matcherName);
      result.jasmineMemberMatcherPath = getJasmineMatcherPath(result.jsDoc.memberMatcherName);
      result.jasmineMemberMatcherTestPath = getJasmineMatcherTestPath(
        result.jsDoc.memberMatcherName,
      );
      result.jestMatcherPath = getJestMatcherPath(result.jsDoc.matcherName);
      result.jestMatcherTestPath = getJestMatcherTestPath(result.jsDoc.matcherName);
      result.matcherInputs = result.inputs.slice(0, result.inputs.length - 1);
      result.memberMatcherInputs = ['propPath: string'].concat(result.matcherInputs);
      result.inputsWithoutTypes = result.inputs.map((withType) => withType.split(':')[0]);
      result.matcherInputsWithoutTypes = result.matcherInputs.map(
        (withType) => withType.split(':')[0],
      );
      result.memberMatcherInputsWithoutTypes = result.memberMatcherInputs.map(
        (withType) => withType.split(':')[0],
      );
    }
    ts.forEachChild(node, visitNode);
  };

  try {
    visitNode(sourceFile);
  } catch (err) {
    console.error('FAIL:', filePath);
    throw err;
  }

  return result;
};

(() => {
  const allMetadata: FileMeta[] = rootFilePaths.map(getMetadata);
  const jasmineMatcherMetadata: FileMeta[] = allMetadata.filter(isAllowableJasmineMatcher);
  const jasmineMemberMatcherMetadata: FileMeta[] = allMetadata.filter(
    isAllowableJasmineMemberMatcher,
  );
  const jestMatcherMetadata: FileMeta[] = allMetadata.filter(isAllowableJestMatcher);

  jasmineMatcherMetadata.forEach(generateJasmineMatcher);
  jasmineMatcherMetadata.forEach(generateJasmineMatcherTest);
  generateJasmineIndex(jasmineMatcherMetadata, jasmineMemberMatcherMetadata);
  jasmineMemberMatcherMetadata.forEach(generateJasmineMemberMatcher);
  jasmineMemberMatcherMetadata.forEach(generateJasmineMemberMatcherTest);

  jestMatcherMetadata.forEach(generateJestMatcher);
  jestMatcherMetadata.forEach(generateJestMatcherTest);
  generateJestIndex();
})();