Ts-morph: Question: purpose of this module

Created on 21 Sep 2019  路  4Comments  路  Source: dsherret/ts-morph

I have been searching for a solution to generate test script with a typescript function. I made little success with only scaffolding it, https://github.com/allenhwkim/ngentest.
Your AST output will be super helpful for my project to generate a function test automatically although I haven't started using it yet.

Although I find parsing Typescript and having AST is very useful, I don't see compiling Typescript within NodeJS will be so helpful. I am pretty sure that you have the reasons that I couldn't find yet.

Furthermore, there is ts-node, https://github.com/TypeStrong/ts-node, which compile typescript and run, and I am also wonder what's difference from ts-node.

I am just curious and posting this question here since this is only way I can communicate with you.

Thanks in advance.

question

Most helpful comment

Here's a real-life example that I implemented during the previous week at work. Since the i18n module for Angular doesn't allow for dynamically translating strings (yet), we needed to have all the enumerations written in the templates with the i18n tag.

For example, the MachineTypeEnum is the following enumeration:

export enum MachineTypeEnum {
  Slow = 0,
  Medium = 1,
  Fast = 2,
}

and it needs a component such as this:

@Component({
  selector: 'vtx-enum-machine-type-enum',
  template: `
    <ng-container [ngSwitch]="enum">
      <ng-container *ngSwitchCase="MachineTypeEnum.Slow" i18n="@@enum_MachineTypeEnum_slow">Slow</ng-container>
      <ng-container *ngSwitchCase="MachineTypeEnum.Medium" i18n="@@enum_MachineTypeEnum_medium">Medium</ng-container>
      <ng-container *ngSwitchCase="MachineTypeEnum.Fast" i18n="@@enum_MachineTypeEnum_fast">Fast</ng-container>
    </ng-container>
  `
})
export class MachineTypeEnumComponent implements EnumComponent<MachineTypeEnum> {
  @Input() public enum?: MachineTypeEnum
  public MachineTypeEnum = MachineTypeEnum
}

Now for this kind of response from the server:

export interface SaveMachineInput {
  SpeedType: MachineTypeEnum
  TypeId: number
  Type: string
  Vendor: string
  VendorId: number
}

we have a template such as

<dl>
  <dt>Speed</dt>
  <dd>
    <vtx-enum-machine-type-enum [enum]="data.SpeedType"></vtx-enum-machine-type-enum>
  </dd>
</dl>

There are over 60 enumerations, some with over 100 members. Instead of typing each one out by hand, I created a script which iterates over all enumerations and creates these components.

The full script

import { EnumDeclaration, Project, SyntaxKind } from 'ts-morph'
import * as path from 'path'
import * as casing from 'change-case'
import { stripIndent } from 'common-tags'

const project = new Project({
  tsConfigFilePath: 'tsconfig.json',
})

const dir = project.addExistingDirectory(path.join('src', 'app', 'services', 'api'))
const files = dir.getSourceFiles()

const enumMap = new Map<string, EnumDeclaration>()

const errors: string[] = []

files.forEach(file => {
  const enums = file.getDescendantsOfKind(SyntaxKind.EnumDeclaration)
  enums.forEach(enumeration => {
    const enumName = enumeration.getName()
    if (enumName == null) {
      throw new Error(`Found enum without name.`)
    }
    const kebab = casing.kebab(enumName)

    if (enumMap.has(kebab)) {
      errors.push(`Two enums would produce the same kebab-case name (${kebab}): ${enumName}.`)
    } else {
      enumMap.set(kebab, enumeration)
    }
  })
})

const distDirPath = path.join('src', 'app', 'enum-strings', 'enum-string')
const distFilePath = path.join(distDirPath, 'enum-string-components.ts')
const distDir = project.createDirectory(distDirPath)
const distFile = project.createSourceFile(distFilePath, {}, { overwrite: true })

const fileTextChunks: Array<{
  fileText: string
  enumPath: string
  enumName: string
  componentName: string
}> = []
const enumMapAsArray = Array.from(enumMap)
const inputName = `enum`
for (const [kebab, enumeration] of enumMapAsArray) {
  const componentName = casing.pascal(kebab) + 'Component'
  const selectorName = casing.kebab('vtx-enum-' + kebab)
  const enumName = enumeration.getName()
  const cases: string[] = enumeration.getMembers().map(member => {
    const memberName = member.getName()
    const i18nId = (`enum_${enumName}_${casing.camel(memberName)}`)
    const sentenceName = casing.sentence(memberName)
    return `<ng-container *ngSwitchCase="${enumName}.${memberName}" i18n="@@${i18nId}">${sentenceName}</ng-container>`
  })
  const fileText = stripIndent(`
    @Component({
      selector: '${selectorName}',
      template: \`
        <ng-container [ngSwitch]="${inputName}">
          ${cases.join('\n          ')}
        </ng-container>
      \`
    })
    export class ${componentName} implements EnumComponent<${enumName}> {
      @Input() public ${inputName}?: ${enumName}
      public ${enumName} = ${enumName}
    }
  `)
  const enumPath = path.relative(
    distFilePath,
    enumeration.getSourceFile().getFilePath().slice(0, -3),
  ).slice(3)
  fileTextChunks.push({ fileText, enumPath, enumName, componentName })
}
const text = [
  `// tslint:disable`,
  `// formatter:off`,
  `import { Component, Input } from '@angular/core'`,
  fileTextChunks.map(({ enumPath, enumName }) => {
    return `import { ${enumName} } from '${enumPath}'`
  }).join('\n'),
  '',
  stripIndent`
    export interface EnumComponent<T> {
      ${inputName}?: T
    }
  `,
  '',
  fileTextChunks.map(({ fileText }) => fileText).join('\n\n'),
  '',
  `export const COMPONENTS = [${fileTextChunks.map(x => x.componentName).join(', ')}]`,
  '',
].join('\n')
distFile.insertText(0, text)

if (errors.length > 0) {
  errors.forEach(error => {
    console.error(error)
  })
  process.exit(1)
} else {
  console.log(enumMap.size)
  project.saveSync()
  process.exit(0)
}

This way, we automated a boring and repetitive task which is prone to errors with a script.

There are other usages, such as refactoring, codemods (Angular's CLI has these when upgrading to a new version, for example one version made a change that a default argument became required and a script traversed AST to add the second argument in case functionCall.args.length < 2), performing additional checks for common mistakes (for example, your own simple lint rules, like making sure that each usage of .reduce functions uses two arguments). Possibilities are endless!

All 4 comments

Your AST output will be super helpful for my project to generate a function test automatically although I haven't started using it yet.

Cool! Yeah, this sounds perfect for that.

Although I find parsing Typescript and having AST is very useful, I don't see compiling Typescript within NodeJS will be so helpful. I am pretty sure that you have the reasons that I couldn't find yet.

This library isn't for compiling or running TypeScript code. It's only for inspecting code and then programmatically changing it. ts-node is useful for having node be able to load .ts, compile to javascript, and then run that in node. This library can't do that.

Let me know if that answered your question!

Thanks for your answer. It's really helpful.

It's only for inspecting code and then programmatically changing it

This means you want to change the code with program?

I don't normally have this situation to change the source code with program. When I want to change code, I code, unit-test it, save it, integrate it, and deploy it.

Can you tell me any examples or situation that you change the code programmatically?

Here's a real-life example that I implemented during the previous week at work. Since the i18n module for Angular doesn't allow for dynamically translating strings (yet), we needed to have all the enumerations written in the templates with the i18n tag.

For example, the MachineTypeEnum is the following enumeration:

export enum MachineTypeEnum {
  Slow = 0,
  Medium = 1,
  Fast = 2,
}

and it needs a component such as this:

@Component({
  selector: 'vtx-enum-machine-type-enum',
  template: `
    <ng-container [ngSwitch]="enum">
      <ng-container *ngSwitchCase="MachineTypeEnum.Slow" i18n="@@enum_MachineTypeEnum_slow">Slow</ng-container>
      <ng-container *ngSwitchCase="MachineTypeEnum.Medium" i18n="@@enum_MachineTypeEnum_medium">Medium</ng-container>
      <ng-container *ngSwitchCase="MachineTypeEnum.Fast" i18n="@@enum_MachineTypeEnum_fast">Fast</ng-container>
    </ng-container>
  `
})
export class MachineTypeEnumComponent implements EnumComponent<MachineTypeEnum> {
  @Input() public enum?: MachineTypeEnum
  public MachineTypeEnum = MachineTypeEnum
}

Now for this kind of response from the server:

export interface SaveMachineInput {
  SpeedType: MachineTypeEnum
  TypeId: number
  Type: string
  Vendor: string
  VendorId: number
}

we have a template such as

<dl>
  <dt>Speed</dt>
  <dd>
    <vtx-enum-machine-type-enum [enum]="data.SpeedType"></vtx-enum-machine-type-enum>
  </dd>
</dl>

There are over 60 enumerations, some with over 100 members. Instead of typing each one out by hand, I created a script which iterates over all enumerations and creates these components.

The full script

import { EnumDeclaration, Project, SyntaxKind } from 'ts-morph'
import * as path from 'path'
import * as casing from 'change-case'
import { stripIndent } from 'common-tags'

const project = new Project({
  tsConfigFilePath: 'tsconfig.json',
})

const dir = project.addExistingDirectory(path.join('src', 'app', 'services', 'api'))
const files = dir.getSourceFiles()

const enumMap = new Map<string, EnumDeclaration>()

const errors: string[] = []

files.forEach(file => {
  const enums = file.getDescendantsOfKind(SyntaxKind.EnumDeclaration)
  enums.forEach(enumeration => {
    const enumName = enumeration.getName()
    if (enumName == null) {
      throw new Error(`Found enum without name.`)
    }
    const kebab = casing.kebab(enumName)

    if (enumMap.has(kebab)) {
      errors.push(`Two enums would produce the same kebab-case name (${kebab}): ${enumName}.`)
    } else {
      enumMap.set(kebab, enumeration)
    }
  })
})

const distDirPath = path.join('src', 'app', 'enum-strings', 'enum-string')
const distFilePath = path.join(distDirPath, 'enum-string-components.ts')
const distDir = project.createDirectory(distDirPath)
const distFile = project.createSourceFile(distFilePath, {}, { overwrite: true })

const fileTextChunks: Array<{
  fileText: string
  enumPath: string
  enumName: string
  componentName: string
}> = []
const enumMapAsArray = Array.from(enumMap)
const inputName = `enum`
for (const [kebab, enumeration] of enumMapAsArray) {
  const componentName = casing.pascal(kebab) + 'Component'
  const selectorName = casing.kebab('vtx-enum-' + kebab)
  const enumName = enumeration.getName()
  const cases: string[] = enumeration.getMembers().map(member => {
    const memberName = member.getName()
    const i18nId = (`enum_${enumName}_${casing.camel(memberName)}`)
    const sentenceName = casing.sentence(memberName)
    return `<ng-container *ngSwitchCase="${enumName}.${memberName}" i18n="@@${i18nId}">${sentenceName}</ng-container>`
  })
  const fileText = stripIndent(`
    @Component({
      selector: '${selectorName}',
      template: \`
        <ng-container [ngSwitch]="${inputName}">
          ${cases.join('\n          ')}
        </ng-container>
      \`
    })
    export class ${componentName} implements EnumComponent<${enumName}> {
      @Input() public ${inputName}?: ${enumName}
      public ${enumName} = ${enumName}
    }
  `)
  const enumPath = path.relative(
    distFilePath,
    enumeration.getSourceFile().getFilePath().slice(0, -3),
  ).slice(3)
  fileTextChunks.push({ fileText, enumPath, enumName, componentName })
}
const text = [
  `// tslint:disable`,
  `// formatter:off`,
  `import { Component, Input } from '@angular/core'`,
  fileTextChunks.map(({ enumPath, enumName }) => {
    return `import { ${enumName} } from '${enumPath}'`
  }).join('\n'),
  '',
  stripIndent`
    export interface EnumComponent<T> {
      ${inputName}?: T
    }
  `,
  '',
  fileTextChunks.map(({ fileText }) => fileText).join('\n\n'),
  '',
  `export const COMPONENTS = [${fileTextChunks.map(x => x.componentName).join(', ')}]`,
  '',
].join('\n')
distFile.insertText(0, text)

if (errors.length > 0) {
  errors.forEach(error => {
    console.error(error)
  })
  process.exit(1)
} else {
  console.log(enumMap.size)
  project.saveSync()
  process.exit(0)
}

This way, we automated a boring and repetitive task which is prone to errors with a script.

There are other usages, such as refactoring, codemods (Angular's CLI has these when upgrading to a new version, for example one version made a change that a default argument became required and a script traversed AST to add the second argument in case functionCall.args.length < 2), performing additional checks for common mistakes (for example, your own simple lint rules, like making sure that each usage of .reduce functions uses two arguments). Possibilities are endless!

Thank you for your detailed explanation.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

pierregallard picture pierregallard  路  4Comments

gt3 picture gt3  路  4Comments

HoldYourWaffle picture HoldYourWaffle  路  4Comments

dsherret picture dsherret  路  3Comments

mi5ha picture mi5ha  路  3Comments