Skip to content

Commit d8098e4

Browse files
chore: add toolkit-ts package (#60)
1 parent d55458e commit d8098e4

21 files changed

+484
-0
lines changed

packages/toolkit-ts/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Toolkit-ts
2+
3+
Contains wrappers and utilities for `ts-morph` to manipulate Typescript AST on a higher level.

packages/toolkit-ts/jest.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { InitialOptionsTsJest } from 'ts-jest'
2+
3+
import baseConfig from '../../jest.config.base'
4+
5+
const config: InitialOptionsTsJest = {
6+
...baseConfig,
7+
displayName: 'codemod-toolkit-ts',
8+
rootDir: __dirname,
9+
}
10+
11+
// eslint-disable-next-line import/no-default-export
12+
export default config

packages/toolkit-ts/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"private": true,
3+
"name": "@sourcegraph/codemod-toolkit-ts",
4+
"version": "1.0.0",
5+
"description": "@sourcegraph/codemod-toolkit-ts",
6+
"license": "Apache-2.0",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"scripts": {
10+
"build": "tsc --build ./tsconfig.build.json",
11+
"build:watch": "tsc --build ./tsconfig.build.json --watch",
12+
"build:clean": "tsc --build --clean ./tsconfig.build.json && rimraf dist ./*.tsbuildinfo",
13+
"typecheck": "tsc --noEmit",
14+
"test": "jest",
15+
"format": "prettier --write \"./**/*.{ts,js,json,md}\" --ignore-path ../../.prettierignore",
16+
"lint": "eslint './src/**/*.ts?(x)'"
17+
},
18+
"dependencies": {
19+
"@sourcegraph/codemod-common": "1.0.0"
20+
}
21+
}

packages/toolkit-ts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './manipulation'
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ImportDeclaration, ImportDeclarationStructure, OptionalKind, SourceFile } from 'ts-morph'
2+
3+
import { checkIfFileHasIdentifier } from './SourceFile'
4+
5+
export function getImportDeclarationByModuleSpecifier(
6+
sourceFile: SourceFile,
7+
moduleSpecifier: string
8+
): ImportDeclaration | undefined {
9+
return sourceFile.getImportDeclarations().find(importDeclaration => {
10+
return importDeclaration.getModuleSpecifierValue() === moduleSpecifier
11+
})
12+
}
13+
14+
export interface AddImportIfIdentifierIsUsedOptions {
15+
sourceFile: SourceFile
16+
importStructure: Omit<OptionalKind<ImportDeclarationStructure>, 'namedImports'> & {
17+
namedImports?: string[]
18+
}
19+
}
20+
21+
/**
22+
* Adds missing declarations to the source file if literals from the provided import structure are used in the code.
23+
* TODO: use type information instead of literal names to understand what needs to be changed.
24+
*/
25+
export function addOrUpdateImportIfIdentifierIsUsed(options: AddImportIfIdentifierIsUsedOptions): void {
26+
const { sourceFile, importStructure } = options
27+
const { namedImports, defaultImport, moduleSpecifier } = importStructure
28+
29+
const usedNamedImports = namedImports?.filter(namedImport => {
30+
return checkIfFileHasIdentifier(sourceFile, namedImport)
31+
})
32+
33+
const usedDefaultImport =
34+
defaultImport && checkIfFileHasIdentifier(sourceFile, defaultImport) ? defaultImport : undefined
35+
36+
const importDeclaration = getImportDeclarationByModuleSpecifier(sourceFile, moduleSpecifier)
37+
38+
if (importDeclaration) {
39+
if (usedNamedImports) {
40+
for (const namedImport of usedNamedImports) {
41+
const isAddedAlready = importDeclaration.getNamedImports().some(existingImport => {
42+
return existingImport.getName() === namedImport
43+
})
44+
45+
if (!isAddedAlready) {
46+
importDeclaration.addNamedImport(namedImport)
47+
}
48+
}
49+
}
50+
51+
if (usedDefaultImport && importDeclaration.getDefaultImport()?.getText() !== usedDefaultImport) {
52+
importDeclaration.setDefaultImport(usedDefaultImport)
53+
}
54+
} else if ((usedNamedImports && usedNamedImports.length > 0) || usedDefaultImport) {
55+
sourceFile.addImportDeclaration({
56+
namedImports: usedNamedImports,
57+
defaultImport: usedDefaultImport,
58+
moduleSpecifier,
59+
})
60+
}
61+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Node } from 'ts-morph'
2+
3+
import { errors } from '@sourcegraph/codemod-common'
4+
5+
export function getParentUntil<T extends Node>(
6+
start: Node,
7+
condition: (parent: Node, child: Node) => parent is T
8+
): T | undefined {
9+
let node = start
10+
let parent = start.getParent()
11+
12+
while (parent && !condition(parent, node)) {
13+
node = parent
14+
parent = node.getParent()
15+
}
16+
17+
return parent
18+
}
19+
20+
export function getParentUntilOrThrow<T extends Node>(
21+
start: Node,
22+
condition: (parent: Node, child: Node) => parent is T
23+
): T {
24+
return errors.throwIfNullOrUndefined(
25+
getParentUntil(start, condition),
26+
'Expected to find a parent matching condition provided.'
27+
)
28+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { SourceFile, SyntaxKind } from 'ts-morph'
2+
3+
export function checkIfFileHasIdentifier(sourceFile: SourceFile, identifierString: string): boolean {
4+
return sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).some(identifier => {
5+
return identifier.getText() === identifierString
6+
})
7+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { SyntaxKind } from 'ts-morph'
2+
3+
import { createSourceFile } from '@sourcegraph/codemod-common'
4+
5+
import { addOrUpdateImportIfIdentifierIsUsed } from '../ImportDeclaration'
6+
7+
const classNamesImportStructure = {
8+
defaultImport: 'classNames',
9+
moduleSpecifier: 'classnames',
10+
}
11+
12+
describe('addOrUpdateImportIfIdentifierIsUsed', () => {
13+
it('adds default import if needed', () => {
14+
const { sourceFile } = createSourceFile('const className = classNames("d-flex", )')
15+
16+
addOrUpdateImportIfIdentifierIsUsed({ sourceFile, importStructure: classNamesImportStructure })
17+
const importDeclaration = sourceFile.getFirstDescendantByKindOrThrow(SyntaxKind.ImportDeclaration)
18+
19+
expect(importDeclaration.getModuleSpecifier().getLiteralValue()).toBe(classNamesImportStructure.moduleSpecifier)
20+
expect(importDeclaration.getDefaultImport()?.getText()).toBe(classNamesImportStructure.defaultImport)
21+
})
22+
23+
it('adds named imports if needed', () => {
24+
const { sourceFile } = createSourceFile('const markup = <Container><Button /><Container>')
25+
const importStructure = {
26+
namedImports: ['Button', 'Container', 'Input'],
27+
moduleSpecifier: '@sourcegraph/wildcard',
28+
}
29+
30+
addOrUpdateImportIfIdentifierIsUsed({ sourceFile, importStructure })
31+
const importDeclaration = sourceFile.getFirstDescendantByKindOrThrow(SyntaxKind.ImportDeclaration)
32+
const namedImports = importDeclaration.getNamedImports()
33+
34+
expect(importDeclaration.getModuleSpecifier().getLiteralValue()).toBe(importStructure.moduleSpecifier)
35+
expect(namedImports.length).toBe(2)
36+
expect(
37+
namedImports.map(namedImport => {
38+
return namedImport.getText()
39+
})
40+
).toEqual(['Button', 'Container'])
41+
})
42+
43+
it('updates existing import declaration if needed', () => {
44+
const { sourceFile } = createSourceFile(`
45+
import { Container } from '@sourcegraph/wildcard'
46+
47+
const markup = <Container><Button /><Container>
48+
`)
49+
50+
const importStructure = {
51+
namedImports: ['Button', 'Container', 'Input'],
52+
moduleSpecifier: '@sourcegraph/wildcard',
53+
}
54+
55+
addOrUpdateImportIfIdentifierIsUsed({ sourceFile, importStructure })
56+
const importDeclaration = sourceFile.getFirstDescendantByKindOrThrow(SyntaxKind.ImportDeclaration)
57+
const namedImports = importDeclaration.getNamedImports()
58+
59+
expect(importDeclaration.getModuleSpecifier().getLiteralValue()).toBe(importStructure.moduleSpecifier)
60+
expect(namedImports.length).toBe(2)
61+
expect(
62+
namedImports.map(namedImport => {
63+
return namedImport.getText()
64+
})
65+
).toEqual(['Container', 'Button'])
66+
})
67+
68+
it('does not add import if it already exists', () => {
69+
const { sourceFile } = createSourceFile(`
70+
import classNames from 'classnames'
71+
72+
const className = classNames("d-flex", )
73+
`)
74+
75+
addOrUpdateImportIfIdentifierIsUsed({ sourceFile, importStructure: classNamesImportStructure })
76+
const importDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.ImportDeclaration)
77+
78+
expect(importDeclarations.length).toBe(1)
79+
})
80+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Node, SyntaxKind } from 'ts-morph'
2+
3+
import { errors, createStringLiteral } from '@sourcegraph/codemod-common'
4+
5+
import { getParentUntilOrThrow } from '../Node'
6+
7+
describe('getParentUntilOrThrow', () => {
8+
const node = createStringLiteral(`
9+
export const Button = () => {
10+
return (
11+
<div>
12+
<button className="btn" />
13+
</div>
14+
)
15+
}
16+
`)
17+
18+
it('returns parent of kind if it exits', () => {
19+
expect(getParentUntilOrThrow(node, Node.isArrowFunction).getKind()).toBe(SyntaxKind.ArrowFunction)
20+
expect(getParentUntilOrThrow(node, Node.isJsxElement).getKind()).toBe(SyntaxKind.JsxElement)
21+
expect(getParentUntilOrThrow(node, Node.isVariableDeclaration).getName()).toBe('Button')
22+
})
23+
24+
it('throws if parent of kind does not exit', () => {
25+
expect(() => {
26+
return getParentUntilOrThrow(node, Node.isClassDeclaration)
27+
}).toThrowError(errors.InvalidOperationError)
28+
})
29+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createSourceFile } from '@sourcegraph/codemod-common'
2+
3+
import { checkIfFileHasIdentifier } from '../SourceFile'
4+
5+
describe('checkIfFileHasIdentifier', () => {
6+
it('returns `true` if source files has identifier', () => {
7+
const { sourceFile } = createSourceFile('const x = 1')
8+
9+
expect(checkIfFileHasIdentifier(sourceFile, 'x')).toBe(true)
10+
})
11+
12+
it('returns `false` if source files does not have identifier', () => {
13+
const { sourceFile } = createSourceFile('const x = 1')
14+
15+
expect(checkIfFileHasIdentifier(sourceFile, 'y')).toBe(false)
16+
})
17+
})

0 commit comments

Comments
 (0)