├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── compiler-plugin │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── createImportRewriter.ts │ │ ├── generateBarrels.ts │ │ ├── index.ts │ │ ├── patchCompilerHostFileResolution.ts │ │ ├── patchCompilerHostFileWriter.ts │ │ └── patchGetSemanticDiagnostics.ts │ ├── tests │ │ ├── __fixtures__ │ │ │ ├── does-not-add-assert-to-non-es-next-modules-when-rewriting-imports │ │ │ │ ├── compilerOptions.json │ │ │ │ ├── expected │ │ │ │ │ ├── fixtures │ │ │ │ │ │ └── users.json │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── fixtures │ │ │ │ │ └── users.json │ │ │ │ │ └── index.ts │ │ │ ├── does-not-create-a-barrel-for-folders-containing-an-index-file │ │ │ │ ├── expected │ │ │ │ │ ├── does-not-have-index │ │ │ │ │ │ └── a.js │ │ │ │ │ ├── has-index │ │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── does-not-have-index │ │ │ │ │ └── a.ts │ │ │ │ │ ├── has-index │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ ├── does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports │ │ │ │ ├── compilerOptions.json │ │ │ │ ├── expected │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Container.jsx │ │ │ │ │ │ └── useButton.js │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ ├── Container.tsx │ │ │ │ │ └── useButton.ts │ │ │ │ │ └── index.ts │ │ │ ├── groups-rewritten-imports-from-the-same-module │ │ │ │ ├── expected │ │ │ │ │ ├── index.js │ │ │ │ │ └── utils │ │ │ │ │ │ ├── a.js │ │ │ │ │ │ └── b.js │ │ │ │ └── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils │ │ │ │ │ ├── a.ts │ │ │ │ │ └── b.ts │ │ │ ├── includes-non-ts-files-in-the-barrel │ │ │ │ ├── expected │ │ │ │ │ ├── fixtures │ │ │ │ │ │ └── users.json │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── fixtures │ │ │ │ │ └── users.json │ │ │ │ │ └── index.ts │ │ │ ├── preserves-barrel-files-if-specified │ │ │ │ ├── expected.diagnostics │ │ │ │ ├── expected │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Button.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── types.js │ │ │ │ │ └── index.js │ │ │ │ ├── options.json │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ ├── Button.ts │ │ │ │ │ └── types.ts │ │ │ │ │ └── index.ts │ │ │ ├── preserves-extension-for-extraneous-file-types-when-rewriting-imports │ │ │ │ ├── expected │ │ │ │ │ ├── fixtures │ │ │ │ │ │ └── users.json │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── fixtures │ │ │ │ │ └── users.json │ │ │ │ │ └── index.ts │ │ │ ├── reports-missing-export-identifier-diagnostics │ │ │ │ ├── expected.diagnostics │ │ │ │ ├── expected │ │ │ │ │ ├── index.js │ │ │ │ │ └── utils │ │ │ │ │ │ └── myFunction.js │ │ │ │ └── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils │ │ │ │ │ └── myFunction.ts │ │ │ └── rewrites-barrel-imports │ │ │ │ ├── expected │ │ │ │ ├── components │ │ │ │ │ ├── Container.jsx │ │ │ │ │ └── useButton.js │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ ├── components │ │ │ │ ├── Container.tsx │ │ │ │ └── useButton.ts │ │ │ │ └── index.ts │ │ ├── compile.ts │ │ └── typescript-compiler-plugin.test.ts │ └── tsconfig.json ├── core │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── calculateBarrel.ts │ │ ├── getDirectoryFiles.ts │ │ ├── getExportsOfSourceFile.ts │ │ ├── getFoldersWithoutIndexFile.ts │ │ ├── index.ts │ │ ├── patchMethod.ts │ │ └── utils.ts │ ├── tests │ │ ├── calculateBarrel │ │ │ ├── calculateBarrel.test.ts │ │ │ └── program │ │ │ │ └── folder │ │ │ │ ├── 0-aliases-default-export.ts │ │ │ │ ├── 1-forwards-member-export.ts │ │ │ │ ├── 2-forwards-named-export.ts │ │ │ │ ├── 3-forwards-aliased-named-export.ts │ │ │ │ ├── 4-forwards-multiple-exports.ts │ │ │ │ ├── 5-forwards-namespace-export.ts │ │ │ │ ├── 6-forwards-type-declarations.ts │ │ │ │ ├── Y-forwards.json │ │ │ │ ├── unnamed-default-export.ts │ │ │ │ ├── unnamed-namespace-export.ts │ │ │ │ └── z-forwards.json │ │ ├── getDirectoryFiles │ │ │ ├── getDirectoryFiles.test.ts │ │ │ └── program │ │ │ │ ├── another-folder │ │ │ │ ├── a.ts │ │ │ │ ├── b.ts │ │ │ │ └── c.ts │ │ │ │ └── folder │ │ │ │ ├── a.ts │ │ │ │ ├── b.ts │ │ │ │ ├── c.ts │ │ │ │ └── file.json │ │ └── getExportsOfSourceFile.test.ts │ └── tsconfig.json ├── eslint-config-custom │ ├── index.js │ └── package.json ├── install │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── utils.ts │ └── tsconfig.json ├── language-service-plugin │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── createBarrelRegistry.ts │ │ ├── createBarrelUpdaterCallback.ts │ │ ├── getOriginalCasedFileName.ts │ │ ├── getProjectInfo.ts │ │ ├── index.ts │ │ ├── patchFindReferences.ts │ │ ├── patchFindRenameLocations.ts │ │ ├── patchGetSemanticDiagnostics.ts │ │ ├── patchLanguageServiceHostModuleResolution.ts │ │ └── patchServerHostFileResolution.ts │ ├── tests │ │ ├── diagnostic-information.test.ts │ │ ├── find-references.test.ts │ │ ├── go-to-definition.test.ts │ │ ├── import-suggestions.test.ts │ │ ├── project.ts │ │ ├── rename-symbols.test.ts │ │ └── type-safety.test.ts │ └── tsconfig.json └── tsconfig │ ├── README.md │ ├── base.json │ └── package.json ├── turbo.json └── usage.gif /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | # yes, we use tabs 6 | indent_style = spaces 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ['custom'], 5 | overrides: [ 6 | { 7 | files: ['./packages/tests/**/*.js'], 8 | rules: { 9 | 'prettier/prettier': 'off', 10 | }, 11 | }, 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # misc 12 | .DS_Store 13 | *.pem 14 | 15 | # debug 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | .pnpm-debug.log* 20 | 21 | # local env files 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | # turbo 28 | .turbo 29 | 30 | dist 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "semi": false, 5 | "arrowParens": "always", 6 | "jsxSingleQuote": false, 7 | "jsxBracketSameLine": false, 8 | "tabWidth": 2, 9 | "useTabs": false, 10 | "bracketSpacing": true 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.formatOnSave": false 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "typescript.tsdk": "node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luis Felipe Zaguini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Virtual Barrel 2 | 3 | Stop manually creating and managing [barrel files](https://basarat.gitbook.io/typescript/main-1/barrel)! This solution creates barrel files at compile time ("virtual", because they don't exist on disk), so that you won't ever need to write one by hand again. 4 | 5 | ![usage](./usage.gif) 6 | 7 | ## What? 8 | 9 | Imagine that you have this folder structure: 10 | 11 | ``` 12 | - components/ 13 | -- Button.tsx 14 | -- Image.tsx 15 | -- Text.tsx 16 | ``` 17 | 18 | And you want to import all of it, or a subset, in a TypeScript file: 19 | 20 | ```ts 21 | import { Button } from './components/Button' 22 | import { Image } from './components/Image' 23 | import { Text } from './components/Text' 24 | 25 | ... 26 | ``` 27 | 28 | As you can imagine, this can turn into a hot mess in no time providing that real applications have dozens, if not hundreds, of exported symbols per folder. 29 | 30 | Because of that, it makes sense to try and lower the amount of import declarations, from a maintenance perspective, and of course, developer ergonomics too. 31 | 32 | So you create a barrel file, an `index.ts` file inside that components folder: 33 | 34 | ```ts 35 | export { Button } from './Button' 36 | export { Image } from './Image' 37 | export { Text } from './Text' 38 | ``` 39 | 40 | Then you can start writing imports like this: 41 | 42 | ```ts 43 | import { Button, Image, Text } from './components' 44 | ``` 45 | 46 | Much better, isn't it? The problem is that you'll need to maintain that `components/index.ts` file you just created. Every new component you'd like to expose through the barrel will need to be added to it. 47 | 48 | But wouldn't it be nice if you _did not need_ to create or manage this file? 49 | 50 | That's where TypeScript Virtual Barrel kicks in. The tool works with the compiler to rewrite the import declarations that refer to folders without an `index.ts` file, looking for the actual exported symbols. 51 | 52 | One great advantage is that this plugin works out of the box with bundlers, so you won't need to modify your build process. 53 | 54 | Not yet convinced? [Try the sample project](https://github.com/zaguiini/typescript-virtual-barrel-sample). 55 | 56 | ## Installation 57 | 58 | ### Automatic installation 59 | 60 | Run `npx @typescript-virtual-barrel/install` at the root of your project. 61 | 62 | The script will install the necessary dependencies, add the plugins to your `tsconfig.json` file, and configure VSCode so that it uses the local installation of TypeScript. 63 | 64 | You can understand more about the installation process by reading the [manual installation](#manual-installation) section. 65 | 66 | ### Manual installation 67 | 68 | You'll need to install three packages using your favorite package manager. Mine is Yarn. 69 | 70 | For TS >5: 71 | 72 | ``` 73 | yarn add -D ts-patch@^3 @typescript-virtual-barrel/compiler-plugin@latest @typescript-virtual-barrel/language-service-plugin@latest 74 | ``` 75 | 76 | For TS <5: 77 | 78 | ``` 79 | yarn add -D ts-patch@^2 @typescript-virtual-barrel/compiler-plugin@^0.0.3 @typescript-virtual-barrel/language-service-plugin@^0.0.7 80 | ``` 81 | 82 | With `ts-patch` installed, modify your `package.json` to include a `postinstall` script: 83 | 84 | ```json 85 | { 86 | "scripts": { 87 | "postinstall": "ts-patch install" 88 | } 89 | } 90 | ``` 91 | 92 | This is necessary because the TypeScript compiler does not accept pre-emit transformers. TypeScript Virtual Barrel needs to calculate the barrel files first, and that operation does not work natively, that's why a patch to the local TypeScript installation is necessary. 93 | 94 | Run `yarn install` so that your installation gets patched. As this is a lifecycle script, future package installations will patch TypeScript by default. You can find more information about `ts-patch` [here](https://github.com/nonara/ts-patch). 95 | 96 | Open the `tsconfig.json` file and add the following entries to the `compilerOptions.plugin` section: 97 | 98 | ```json 99 | { 100 | "compilerOptions": { 101 | "plugins": [ 102 | { 103 | "transform": "@typescript-virtual-barrel/compiler-plugin", 104 | "transformProgram": true 105 | }, 106 | { 107 | "name": "@typescript-virtual-barrel/language-service-plugin" 108 | } 109 | ] 110 | } 111 | } 112 | ``` 113 | 114 | The reason that two plugins are added to your config file is that most IDEs are using a Language Server in the background to provide rich information (such as type assertions, import suggestions, go to definition, etc) for you, the developer. As the TypeScript compiler and Language Service are two different things, we need two different plugins to make it work as intended. 115 | 116 | If you're using VSCode, make sure that the Language Server is using the local TypeScript installation. You can do that by creating a file called `.vscode/settings.json` in the root dir of your project with the following contents: 117 | 118 | ```json 119 | { 120 | "typescript.tsdk": "node_modules/typescript/lib" 121 | } 122 | ``` 123 | 124 | ## Options 125 | 126 | The `@typescript-virtual-barrel/compiler-plugin` has an option called `shouldTransformImports`, which is enabled by default. That means that the barrel import declaration is rewritten into multiple declarations at build time. But, for some reason, you might want to preserve the generated barrel in the distribution (emitted) version of your project. That's possible adding `shouldTransformImports: false` to the plugin entry: 127 | 128 | ```json 129 | { 130 | "transform": "@typescript-virtual-barrel/compiler-plugin", 131 | "transformProgram": true, 132 | "shouldTransformImports": false 133 | } 134 | ``` 135 | 136 | As an example, this virtual barrel file import declaration: 137 | 138 | ```typescript 139 | import { Button, Card } from './components' 140 | ``` 141 | 142 | Outputs... 143 | 144 | ```typescript 145 | import { Button } from './components/Button.tsx' 146 | import { Image } from './components/Image.tsx' 147 | ``` 148 | 149 | With this option, the virtual `./components/index.ts` file is **not emitted**. 150 | 151 | ## Usage 152 | 153 | Every time that you have a folder within your project that does not have an `index.ts` file, TypeScript Virtual Barrel will generate one on-the-fly, allowing you to save on those import declarations and not include a useless `index.ts` file to your codebase. 154 | 155 | All you need to do is configure the plugin and start writing import declarations. Or, better yet, just accept import suggestions 😎 156 | 157 | ![usage](./usage.gif) 158 | 159 | Notice how, in the GIF, I'm getting symbols ready to be imported. That's right: full IDE support! It also regenerate barrels in real time if you change the files in the barrel folder, giving you the best possible developer experience. ✨ 160 | 161 | And of course, you can compile your project, just like you normally would: `tsc`. It will create the distribution version, either transforming the imports or [keeping the generated barrel file if you wish](#options). 162 | 163 | ## License 164 | 165 | MIT 166 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-virtual-barrel", 3 | "version": "0.0.1", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "build": "turbo run build", 10 | "dev": "turbo run dev --parallel", 11 | "lint": "turbo run lint", 12 | "test": "turbo run test", 13 | "format": "prettier --write \"**/*.{ts,tsx,md}\"" 14 | }, 15 | "devDependencies": { 16 | "@types/ts-expose-internals": "npm:ts-expose-internals@5.4.2", 17 | "eslint-config-custom": "*", 18 | "prettier": "latest", 19 | "turbo": "latest" 20 | }, 21 | "engines": { 22 | "node": ">=14.0.0" 23 | }, 24 | "packageManager": "npm@8.18.0" 25 | } 26 | -------------------------------------------------------------------------------- /packages/compiler-plugin/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testMatch: ['/tests/**/*.test.ts'], 5 | testEnvironment: 'node', 6 | } 7 | -------------------------------------------------------------------------------- /packages/compiler-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typescript-virtual-barrel/compiler-plugin", 3 | "version": "1.0.0", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "files": [ 7 | "dist/**" 8 | ], 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "tsc --watch", 13 | "lint": "TIMING=1 eslint src/**/*.ts* --fix", 14 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", 15 | "test": "jest" 16 | }, 17 | "devDependencies": { 18 | "@types/jest": "^29.0.2", 19 | "@types/node": "^18.7.1", 20 | "eslint": "^7.32.0", 21 | "eslint-config-custom": "*", 22 | "jest": "^29.0.3", 23 | "jest-diff": "^29.5.0", 24 | "rimraf": "^4.4.0", 25 | "ts-jest": "^29.0.1", 26 | "tsconfig": "*" 27 | }, 28 | "dependencies": { 29 | "@typescript-virtual-barrel/core": ">=1.0.0", 30 | "ts-patch": "^3.1.2", 31 | "typescript": "^5.4.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/createImportRewriter.ts: -------------------------------------------------------------------------------- 1 | import typescript, { 2 | CompilerHost, 3 | TransformationContext, 4 | SourceFile, 5 | Node, 6 | } from 'typescript' 7 | import { 8 | isESModule, 9 | ExportedEntities, 10 | ExportedEntity, 11 | isNodeModernModuleResolution, 12 | } from '@typescript-virtual-barrel/core' 13 | import path from 'path' 14 | 15 | const createImport = ({ 16 | importSpecifier, 17 | exportFromBarrel, 18 | barrelLocation, 19 | compilerOptions, 20 | }: { 21 | importSpecifier: typescript.ImportSpecifier 22 | exportFromBarrel: ExportedEntity 23 | barrelLocation: string 24 | compilerOptions: typescript.CompilerOptions 25 | }) => { 26 | const { factory } = typescript 27 | 28 | const importClause = factory.createImportClause( 29 | false, 30 | exportFromBarrel.isDefaultExport ? importSpecifier.name : undefined, 31 | exportFromBarrel.isDefaultExport 32 | ? undefined 33 | : factory.createNamedImports([importSpecifier]) 34 | ) 35 | 36 | const extension = typescript.extensionFromPath(exportFromBarrel.fileName) 37 | const fileNameWithoutExtension = path.basename( 38 | exportFromBarrel.fileName, 39 | extension 40 | ) 41 | 42 | const isExtraneousExtension = !typescript.extensionIsTS(extension) 43 | const shouldAddAssertClause = 44 | isExtraneousExtension && isESModule(compilerOptions) 45 | 46 | const isNodeModernResolution = isNodeModernModuleResolution(compilerOptions) 47 | 48 | const moduleName = isExtraneousExtension 49 | ? exportFromBarrel.fileName 50 | : isNodeModernResolution 51 | ? `${fileNameWithoutExtension}${typescript.getOutputExtension( 52 | exportFromBarrel.fileName, 53 | compilerOptions 54 | )}` 55 | : fileNameWithoutExtension 56 | 57 | const moduleSpecifier = isNodeModernResolution 58 | ? typescript.combinePaths(path.dirname(barrelLocation), moduleName) 59 | : typescript.combinePaths(barrelLocation, moduleName) 60 | 61 | return factory.createImportDeclaration( 62 | undefined, 63 | importClause, 64 | factory.createStringLiteral(moduleSpecifier), 65 | shouldAddAssertClause 66 | ? factory.createAssertClause( 67 | factory.createNodeArray([ 68 | factory.createAssertEntry( 69 | factory.createIdentifier('type'), 70 | factory.createStringLiteral(extension.substring(1)) 71 | ), 72 | ]), 73 | false 74 | ) 75 | : undefined 76 | ) 77 | } 78 | 79 | const expandNamedImports = ( 80 | namedImports: typescript.NamedImports, 81 | barrelLocation: string, 82 | barrelEntities: ExportedEntities, 83 | compilerOptions: typescript.CompilerOptions 84 | ) => { 85 | return namedImports.elements 86 | .filter( 87 | (element) => 88 | barrelEntities[element.propertyName?.text ?? element.name.text] 89 | ) 90 | .map((element) => { 91 | const exportFromBarrel = 92 | barrelEntities[element.propertyName?.text ?? element.name.text] 93 | 94 | return createImport({ 95 | importSpecifier: element, 96 | exportFromBarrel, 97 | barrelLocation, 98 | compilerOptions, 99 | }) 100 | }) 101 | } 102 | 103 | const expandNamespaceImport = ( 104 | namespaceIdentifier: string, 105 | barrelLocation: string, 106 | barrelEntities: ExportedEntities, 107 | compilerOptions: typescript.CompilerOptions 108 | ) => { 109 | const expandedImportDeclarations = Object.keys(barrelEntities).map( 110 | (element) => { 111 | const exportFromBarrel = barrelEntities[element] 112 | 113 | return createImport({ 114 | importSpecifier: typescript.factory.createImportSpecifier( 115 | exportFromBarrel.isTypeExport, 116 | typescript.factory.createIdentifier(element), 117 | typescript.factory.createIdentifier( 118 | `${namespaceIdentifier}${element}` 119 | ) 120 | ), 121 | exportFromBarrel, 122 | barrelLocation, 123 | compilerOptions, 124 | }) 125 | } 126 | ) 127 | 128 | return expandedImportDeclarations 129 | } 130 | 131 | const injectNamespaces = (namespaces: Map) => { 132 | const { factory } = typescript 133 | 134 | return [...namespaces.entries()].map(([namespace, entities]) => { 135 | return factory.createVariableStatement( 136 | undefined, 137 | factory.createVariableDeclarationList( 138 | [ 139 | factory.createVariableDeclaration( 140 | factory.createIdentifier(namespace), 141 | undefined, 142 | undefined, 143 | factory.createObjectLiteralExpression( 144 | Object.entries(entities) 145 | .filter(([, entity]) => !entity.isTypeExport) 146 | .map(([symbol]) => { 147 | return factory.createPropertyAssignment( 148 | factory.createIdentifier(symbol), 149 | factory.createIdentifier(`${namespace}${symbol}`) 150 | ) 151 | }), 152 | true 153 | ) 154 | ), 155 | ], 156 | typescript.NodeFlags.Const 157 | ) 158 | ) 159 | }) 160 | } 161 | 162 | const organizeImports = (imports: typescript.ImportDeclaration[]) => { 163 | const importGroups = imports.reduce< 164 | Record 165 | >((curr, next) => { 166 | if (!typescript.isStringLiteral(next.moduleSpecifier)) { 167 | throw new Error('what?') 168 | } 169 | 170 | if (curr[next.moduleSpecifier.text]) { 171 | curr[next.moduleSpecifier.text].push(next) 172 | } else { 173 | curr[next.moduleSpecifier.text] = [next] 174 | } 175 | 176 | return curr 177 | }, {}) 178 | 179 | return Object.values(importGroups) 180 | .map((importGroup) => 181 | typescript.OrganizeImports.coalesceImports(importGroup, false) 182 | ) 183 | .flat() as typescript.ImportDeclaration[] 184 | } 185 | 186 | type CreateImportRewritterParameters = { 187 | tsInstance: typeof typescript 188 | host: CompilerHost 189 | compilerOptions: typescript.CompilerOptions 190 | getBarrelEntities: (fileName: string) => ExportedEntities | undefined 191 | } 192 | 193 | /** 194 | * Expand the barrels. 195 | */ 196 | export function createImportRewriter({ 197 | tsInstance, 198 | host, 199 | compilerOptions, 200 | getBarrelEntities, 201 | }: CreateImportRewritterParameters) { 202 | const rewriter = { 203 | importRewriter, 204 | hasRewrittenImports: false, 205 | } 206 | 207 | function importRewriter(transformationContext: TransformationContext) { 208 | return (sourceFile: SourceFile) => { 209 | const resolveBarrel = (moduleName: typescript.StringLiteral) => { 210 | const barrelFilePath = 211 | tsInstance.resolveModuleName( 212 | moduleName.text, 213 | sourceFile.fileName, 214 | compilerOptions, 215 | host 216 | ).resolvedModule?.resolvedFileName ?? '' 217 | 218 | return getBarrelEntities(barrelFilePath) 219 | } 220 | 221 | const namespacesToInject = new Map() 222 | 223 | /* Visitor Function */ 224 | const visitNode = (node: Node): Node | Node[] => { 225 | if (!tsInstance.isImportDeclaration(node)) { 226 | return node 227 | } 228 | 229 | if ( 230 | tsInstance.isImportDeclaration(node) && 231 | node.importClause?.namedBindings && 232 | tsInstance.isStringLiteral(node.moduleSpecifier) 233 | ) { 234 | const barrelEntities = resolveBarrel(node.moduleSpecifier) 235 | 236 | if ( 237 | barrelEntities && 238 | tsInstance.isNamedImports(node.importClause.namedBindings) 239 | ) { 240 | rewriter.hasRewrittenImports = true 241 | 242 | return organizeImports( 243 | expandNamedImports( 244 | node.importClause.namedBindings, 245 | node.moduleSpecifier.text, 246 | barrelEntities, 247 | compilerOptions 248 | ) 249 | ) 250 | } 251 | 252 | if ( 253 | barrelEntities && 254 | tsInstance.isNamespaceImport(node.importClause.namedBindings) 255 | ) { 256 | rewriter.hasRewrittenImports = true 257 | 258 | namespacesToInject.set( 259 | node.importClause.namedBindings.name.text, 260 | barrelEntities 261 | ) 262 | 263 | return organizeImports( 264 | expandNamespaceImport( 265 | node.importClause.namedBindings.name.text, 266 | node.moduleSpecifier.text, 267 | barrelEntities, 268 | compilerOptions 269 | ) 270 | ) 271 | } 272 | } 273 | 274 | return tsInstance.visitEachChild(node, visitNode, transformationContext) 275 | } 276 | 277 | const expandedImportsSouceFile = tsInstance.visitEachChild( 278 | sourceFile, 279 | visitNode, 280 | transformationContext 281 | ) 282 | 283 | if (namespacesToInject.size === 0) { 284 | return expandedImportsSouceFile 285 | } 286 | 287 | const firstNonImportStatementIndex = 288 | tsInstance.findLastIndex( 289 | expandedImportsSouceFile.statements, 290 | tsInstance.isImportDeclaration 291 | ) + 1 292 | 293 | return tsInstance.factory.updateSourceFile(expandedImportsSouceFile, [ 294 | ...expandedImportsSouceFile.statements.slice( 295 | 0, 296 | firstNonImportStatementIndex 297 | ), 298 | ...injectNamespaces(namespacesToInject), 299 | ...expandedImportsSouceFile.statements.slice( 300 | firstNonImportStatementIndex 301 | ), 302 | ]) 303 | } 304 | } 305 | 306 | /* Transformer Function */ 307 | return rewriter 308 | } 309 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/generateBarrels.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | 3 | import { 4 | ExportedEntities, 5 | calculateBarrel, 6 | } from '@typescript-virtual-barrel/core' 7 | 8 | type BarrelFile = { 9 | sourceFile: typescript.SourceFile 10 | barrelEntities: ExportedEntities 11 | } 12 | 13 | export type BarrelCache = Map 14 | 15 | export type DiagnosticCache = Map 16 | 17 | type GenerateBarrelsParameters = { 18 | folders: string[] 19 | program: typescript.Program 20 | readDirectory: typescript.CompilerHost['readDirectory'] 21 | tsInstance: typeof typescript 22 | } 23 | 24 | export const generateBarrelsForFolders = ({ 25 | folders, 26 | program, 27 | readDirectory, 28 | tsInstance, 29 | }: GenerateBarrelsParameters) => { 30 | const { printFile } = tsInstance.createPrinter() 31 | 32 | const barrelCache: BarrelCache = new Map() 33 | let diagnosticCache: DiagnosticCache = new Map() 34 | 35 | folders.forEach((folderPath) => { 36 | const fileName = tsInstance.normalizePath( 37 | tsInstance.combinePaths(folderPath, 'index.ts') 38 | ) 39 | 40 | const newBarrel = calculateBarrel( 41 | program, 42 | folderPath, 43 | readDirectory ?? tsInstance.sys.readDirectory 44 | ) 45 | 46 | const sourceFile = tsInstance.createSourceFile( 47 | fileName, 48 | printFile(newBarrel.sourceFile) || 'export {};', 49 | tsInstance.ScriptTarget.Latest 50 | ) 51 | 52 | barrelCache.set(fileName, { 53 | sourceFile, 54 | barrelEntities: newBarrel.barrelEntities, 55 | }) 56 | 57 | if (newBarrel.diagnostics.size > 0) { 58 | diagnosticCache = new Map([...diagnosticCache, ...newBarrel.diagnostics]) 59 | } 60 | }) 61 | 62 | return { barrelCache, diagnosticCache } 63 | } 64 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import { CompilerHost, Program } from 'typescript' 2 | import { PluginConfig, ProgramTransformerExtras } from 'ts-patch' 3 | import { getFoldersWithoutIndexFile } from '@typescript-virtual-barrel/core' 4 | import { generateBarrelsForFolders } from './generateBarrels' 5 | import { patchCompilerHostFileResolution } from './patchCompilerHostFileResolution' 6 | import { patchCompilerHostFileWriter } from './patchCompilerHostFileWriter' 7 | import { patchGetSemanticDiagnostics } from './patchGetSemanticDiagnostics' 8 | 9 | export default function transformProgram( 10 | program: Program, 11 | host: CompilerHost | undefined, 12 | { shouldTransformImports = true }: PluginConfig, 13 | { ts: tsInstance }: ProgramTransformerExtras 14 | ): Program { 15 | const compilerOptions = program.getCompilerOptions() 16 | 17 | /** 18 | * We scan the project looking for folders that do not have an index file. 19 | */ 20 | const rootFileNames = program.getRootFileNames().map(tsInstance.normalizePath) 21 | const projectFoldersWithoutIndexFile = 22 | getFoldersWithoutIndexFile(rootFileNames) 23 | 24 | /** 25 | * Then we generate the barrels for those folders. 26 | */ 27 | const { barrelCache, diagnosticCache } = generateBarrelsForFolders({ 28 | folders: projectFoldersWithoutIndexFile, 29 | program, 30 | readDirectory: host?.readDirectory, 31 | tsInstance, 32 | }) 33 | 34 | const compilerHost = 35 | host ?? tsInstance.createCompilerHost(compilerOptions, true) 36 | 37 | /** 38 | * Then we modify the compiler host in order to find the generated barrels 39 | * when building the TypeScript program. 40 | */ 41 | patchCompilerHostFileResolution({ compilerHost, barrelCache }) 42 | 43 | /** 44 | * We also need to patch the writeFile function so that we either 45 | * write transformed files, or write the barrel files when emitting. 46 | */ 47 | patchCompilerHostFileWriter({ 48 | compilerHost, 49 | barrelCache, 50 | compilerOptions, 51 | shouldTransformImports, 52 | tsInstance, 53 | }) 54 | 55 | /** 56 | * Then, we need to recreate the program with our additional files 57 | * so TypeScript knows about their existence. 58 | */ 59 | const newProgram = tsInstance.createProgram( 60 | [...rootFileNames, ...barrelCache.keys()], 61 | compilerOptions, 62 | compilerHost 63 | ) 64 | 65 | /** 66 | * Finally, we want to include the issues we found when 67 | * scanning the folders so that we can help the user 68 | * troubleshoot potential odd behaviors. 69 | */ 70 | patchGetSemanticDiagnostics({ 71 | program: newProgram, 72 | diagnosticCache, 73 | }) 74 | 75 | return newProgram 76 | } 77 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/patchCompilerHostFileResolution.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import typescript from 'typescript' 3 | 4 | type PatchCompilerHostFileResolutionParameters = { 5 | compilerHost: typescript.CompilerHost 6 | barrelCache: Map 7 | } 8 | 9 | export const patchCompilerHostFileResolution = ({ 10 | compilerHost, 11 | barrelCache, 12 | }: PatchCompilerHostFileResolutionParameters) => { 13 | patchMethod(compilerHost, 'fileExists', (original, fileName) => { 14 | if (barrelCache.has(fileName)) { 15 | return true 16 | } 17 | 18 | return original(fileName) 19 | }) 20 | 21 | patchMethod(compilerHost, 'getSourceFile', (original, fileName, ...args) => { 22 | const cacheEntry = barrelCache.get(fileName) 23 | 24 | if (cacheEntry) { 25 | return cacheEntry.sourceFile 26 | } 27 | 28 | return original(fileName, ...args) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/patchCompilerHostFileWriter.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import typescript from 'typescript' 3 | import { createImportRewriter } from './createImportRewriter' 4 | import { BarrelCache } from './generateBarrels' 5 | 6 | type PatchCompilerHostFileWriterParameters = { 7 | compilerHost: typescript.CompilerHost 8 | tsInstance: typeof typescript 9 | compilerOptions: typescript.CompilerOptions 10 | shouldTransformImports: boolean 11 | barrelCache: BarrelCache 12 | } 13 | 14 | export const patchCompilerHostFileWriter = ({ 15 | compilerHost, 16 | tsInstance, 17 | compilerOptions, 18 | shouldTransformImports, 19 | barrelCache, 20 | }: PatchCompilerHostFileWriterParameters) => { 21 | const { printFile } = tsInstance.createPrinter({ 22 | module: compilerOptions.module, 23 | target: compilerOptions.target, 24 | }) 25 | 26 | patchMethod( 27 | compilerHost, 28 | 'writeFile', 29 | (original, fileName, text, writeBOM, onError, sourceFiles, data) => { 30 | /* User said we should preserve the generated barrel file, so let's write it */ 31 | if (!shouldTransformImports) { 32 | return original(fileName, text, writeBOM, onError, sourceFiles, data) 33 | } 34 | 35 | const [sourceFile] = sourceFiles ?? [] 36 | 37 | /* Did not find the related source file, let's forward it to the original TS function */ 38 | if (!sourceFile) { 39 | return original(fileName, text, writeBOM, onError, sourceFiles, data) 40 | } 41 | 42 | /* User said we should transform imports, so let's not write the barrel file */ 43 | if (barrelCache.has(sourceFile.fileName)) { 44 | return 45 | } 46 | 47 | /* Time to rewrite some imports! */ 48 | const transformer = createImportRewriter({ 49 | tsInstance, 50 | host: compilerHost, 51 | compilerOptions, 52 | getBarrelEntities: (barrelFileName) => 53 | barrelCache.get(barrelFileName)?.barrelEntities, 54 | }) 55 | 56 | const [transformedFile] = tsInstance.transform( 57 | (sourceFile.original as typescript.SourceFile) ?? sourceFile, 58 | [transformer.importRewriter], 59 | compilerOptions 60 | ).transformed 61 | 62 | /* No barrel imports detected -- let's just write the original file */ 63 | if (!transformer.hasRewrittenImports) { 64 | return original(fileName, text, writeBOM, onError, sourceFiles, data) 65 | } 66 | 67 | /* The file was transformed, so let's emit the transformed version */ 68 | const modifiedFile = tsInstance.createSourceFile( 69 | transformedFile.fileName, 70 | printFile(transformedFile) || 'export {};', 71 | compilerOptions.target ?? tsInstance.ScriptTarget.ES3, 72 | true, 73 | transformedFile.scriptKind 74 | ) 75 | 76 | /* Since the `writeFile` function runs in the emit phase, we need to 77 | * transpile the file to match the target runtime 78 | * specified in the compiler options. 79 | */ 80 | const output = tsInstance.transpileModule(modifiedFile.text, { 81 | compilerOptions, 82 | }).outputText 83 | 84 | return original(fileName, output, writeBOM, onError, sourceFiles, data) 85 | } 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /packages/compiler-plugin/src/patchGetSemanticDiagnostics.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | import { DiagnosticCache } from './generateBarrels' 3 | import { patchMethod } from '@typescript-virtual-barrel/core' 4 | 5 | type PatchGetSemanticDiagnosticsParameters = { 6 | program: typescript.Program 7 | diagnosticCache: DiagnosticCache 8 | } 9 | 10 | export const patchGetSemanticDiagnostics = ({ 11 | program, 12 | diagnosticCache, 13 | }: PatchGetSemanticDiagnosticsParameters) => { 14 | patchMethod( 15 | program, 16 | 'getSemanticDiagnostics', 17 | (original, sourceFile, ...args) => { 18 | const originalDiagnostics = original(sourceFile, ...args) 19 | 20 | if (sourceFile) { 21 | const additionalDiagnostics = diagnosticCache.get(sourceFile.fileName) 22 | 23 | if (additionalDiagnostics) { 24 | return [...originalDiagnostics, ...additionalDiagnostics] 25 | } 26 | 27 | return originalDiagnostics 28 | } 29 | 30 | const additionalDiagnostics = [...diagnosticCache.values()].flat() 31 | 32 | return [...originalDiagnostics, ...additionalDiagnostics] 33 | } 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-add-assert-to-non-es-next-modules-when-rewriting-imports/compilerOptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": 1, 3 | "module": 1, 4 | "moduleResolution": 2, 5 | "esModuleInterop": true 6 | } 7 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-add-assert-to-non-es-next-modules-when-rewriting-imports/expected/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-add-assert-to-non-es-next-modules-when-rewriting-imports/expected/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | var users_json_1 = __importDefault(require("./fixtures/users.json")); 7 | console.log(users_json_1.default); 8 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-add-assert-to-non-es-next-modules-when-rewriting-imports/src/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-add-assert-to-non-es-next-modules-when-rewriting-imports/src/index.ts: -------------------------------------------------------------------------------- 1 | import { usersJson } from './fixtures' 2 | 3 | console.log(usersJson) 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/expected/does-not-have-index/a.js: -------------------------------------------------------------------------------- 1 | export const value = 1; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/expected/has-index/index.js: -------------------------------------------------------------------------------- 1 | export const value = 2; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/expected/index.js: -------------------------------------------------------------------------------- 1 | import { value as DoesntHaveIndexvalue } from "./does-not-have-index/a.js"; 2 | import * as HasIndex from './has-index/index.js'; 3 | const DoesntHaveIndex = { 4 | value: DoesntHaveIndexvalue 5 | }; 6 | console.log(DoesntHaveIndex.value + HasIndex.value); 7 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/src/does-not-have-index/a.ts: -------------------------------------------------------------------------------- 1 | export const value = 1 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/src/has-index/index.ts: -------------------------------------------------------------------------------- 1 | export const value = 2 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-create-a-barrel-for-folders-containing-an-index-file/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as DoesntHaveIndex from './does-not-have-index/index.js' 2 | import * as HasIndex from './has-index/index.js' 3 | 4 | console.log(DoesntHaveIndex.value + HasIndex.value) 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/compilerOptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleResolution": 2 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/expected/components/Container.jsx: -------------------------------------------------------------------------------- 1 | export const Container = () => null; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/expected/components/useButton.js: -------------------------------------------------------------------------------- 1 | export const useButton = () => { 2 | /* empty */ 3 | }; 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/expected/index.js: -------------------------------------------------------------------------------- 1 | import { Container } from "./components/Container"; 2 | import { useButton } from "./components/useButton"; 3 | Container(); 4 | useButton(); 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | export const Container = () => null 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/src/components/useButton.ts: -------------------------------------------------------------------------------- 1 | export const useButton = () => { 2 | /* empty */ 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/does-not-include-extensions-for-non-es-node-module-resolution-projects-when-rewriting-imports/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Container, useButton } from './components' 2 | 3 | Container() 4 | useButton() 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/expected/index.js: -------------------------------------------------------------------------------- 1 | import myFunctionA, { myValueA } from "./utils/a.js"; 2 | import myFunctionB, { myValueB } from "./utils/b.js"; 3 | myFunctionA(); 4 | myFunctionB(); 5 | const myValueC = myValueA + myValueB; 6 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/expected/utils/a.js: -------------------------------------------------------------------------------- 1 | export default function myFunctionA() { 2 | // empty 3 | } 4 | export const myValueA = 1; 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/expected/utils/b.js: -------------------------------------------------------------------------------- 1 | export default function myFunctionB() { 2 | // empty 3 | } 4 | export const myValueB = 2; 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/src/index.ts: -------------------------------------------------------------------------------- 1 | import { myFunctionA, myValueA, myFunctionB, myValueB } from './utils/index.js' 2 | 3 | myFunctionA() 4 | myFunctionB() 5 | 6 | const myValueC = myValueA + myValueB 7 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/src/utils/a.ts: -------------------------------------------------------------------------------- 1 | export default function myFunctionA() { 2 | // empty 3 | } 4 | 5 | export const myValueA = 1 6 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/groups-rewritten-imports-from-the-same-module/src/utils/b.ts: -------------------------------------------------------------------------------- 1 | export default function myFunctionB() { 2 | // empty 3 | } 4 | 5 | export const myValueB = 2 6 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/includes-non-ts-files-in-the-barrel/expected/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/includes-non-ts-files-in-the-barrel/expected/index.js: -------------------------------------------------------------------------------- 1 | import fixturesusersJson from "./fixtures/users.json" assert { type: "json" }; 2 | const fixtures = { 3 | usersJson: fixturesusersJson 4 | }; 5 | console.log(fixtures.usersJson); 6 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/includes-non-ts-files-in-the-barrel/src/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/includes-non-ts-files-in-the-barrel/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from './fixtures/index.js' 2 | 3 | console.log(fixtures.usersJson) 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/expected.diagnostics: -------------------------------------------------------------------------------- 1 | src/index.ts(5,7): error TS2322: Type 'false' is not assignable to type 'true'. 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/expected/components/Button.js: -------------------------------------------------------------------------------- 1 | export const Button = () => null; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/expected/components/index.js: -------------------------------------------------------------------------------- 1 | export { Button } from "./Button.js"; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/expected/components/types.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/expected/index.js: -------------------------------------------------------------------------------- 1 | import * as Components from './components/index.js'; 2 | Components.Button(); 3 | const conflictingValue = false; 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/options.json: -------------------------------------------------------------------------------- 1 | { 2 | "shouldTransformImports": false 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/src/components/Button.ts: -------------------------------------------------------------------------------- 1 | export const Button = () => null 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/src/components/types.ts: -------------------------------------------------------------------------------- 1 | export type MyTruthyType = true 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-barrel-files-if-specified/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Components from './components/index.js' 2 | 3 | Components.Button() 4 | 5 | const conflictingValue: Components.MyTruthyType = false 6 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-extension-for-extraneous-file-types-when-rewriting-imports/expected/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-extension-for-extraneous-file-types-when-rewriting-imports/expected/index.js: -------------------------------------------------------------------------------- 1 | import usersJson from "./fixtures/users.json" assert { type: "json" }; 2 | console.log(usersJson); 3 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-extension-for-extraneous-file-types-when-rewriting-imports/src/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "John Doe" 5 | }, 6 | { 7 | "id": 2, 8 | "name": "Jane Doe" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/preserves-extension-for-extraneous-file-types-when-rewriting-imports/src/index.ts: -------------------------------------------------------------------------------- 1 | import { usersJson } from './fixtures/index.js' 2 | 3 | console.log(usersJson) 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/reports-missing-export-identifier-diagnostics/expected.diagnostics: -------------------------------------------------------------------------------- 1 | src/index.ts(1,10): error TS2305: Module '"./utils"' has no exported member 'myFunction'. 2 | src/utils/myFunction.ts(1,1): warning TS9999: Missing identifier for export. This member will not be included in the barrel. 3 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/reports-missing-export-identifier-diagnostics/expected/index.js: -------------------------------------------------------------------------------- 1 | myFunction(); 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/reports-missing-export-identifier-diagnostics/expected/utils/myFunction.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | /* i won't be included in the barrel */ 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/reports-missing-export-identifier-diagnostics/src/index.ts: -------------------------------------------------------------------------------- 1 | import { myFunction } from './utils' 2 | 3 | myFunction() 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/reports-missing-export-identifier-diagnostics/src/utils/myFunction.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | /* i won't be included in the barrel */ 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/expected/components/Container.jsx: -------------------------------------------------------------------------------- 1 | export const Container = () => null; 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/expected/components/useButton.js: -------------------------------------------------------------------------------- 1 | export const useButton = () => { 2 | /* empty */ 3 | }; 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/expected/index.js: -------------------------------------------------------------------------------- 1 | import { Container } from "./components/Container.jsx"; 2 | import { useButton } from "./components/useButton.js"; 3 | Container(); 4 | useButton(); 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | export const Container = () => null 2 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/src/components/useButton.ts: -------------------------------------------------------------------------------- 1 | export const useButton = () => { 2 | /* empty */ 3 | } 4 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/__fixtures__/rewrites-barrel-imports/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Container, useButton } from './components/index.js' 2 | 3 | Container() 4 | useButton() 5 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/compile.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'fs' 2 | import path from 'path' 3 | import typescript from 'typescript' 4 | import transformProgram from '../src' 5 | 6 | export const getDirFiles = (dir: string): string[] => { 7 | const dirents = readdirSync(dir, { withFileTypes: true }) 8 | 9 | const files = dirents.map((dirent) => { 10 | const res = path.resolve(dir, dirent.name) 11 | return dirent.isDirectory() ? getDirFiles(res) : res 12 | }) 13 | 14 | return files.flat() 15 | } 16 | 17 | export const compile = async ( 18 | fixture: string, 19 | transformerOptions: { shouldTransformImports?: boolean } = {}, 20 | compilerOptions: typescript.CompilerOptions = {} 21 | ) => { 22 | const fixtureDir = path.join(__dirname, '__fixtures__', fixture) 23 | const outDir = path.join(fixtureDir, 'expected') 24 | 25 | const options: typescript.CompilerOptions = { 26 | target: typescript.ScriptTarget.ESNext, 27 | outDir, 28 | module: typescript.ModuleKind.ESNext, 29 | moduleResolution: typescript.ModuleResolutionKind.NodeNext, 30 | jsx: typescript.JsxEmit.Preserve, 31 | allowSyntheticDefaultImports: true, 32 | resolveJsonModule: true, 33 | skipLibCheck: true, 34 | ...compilerOptions, 35 | } 36 | 37 | const fileMap = new Map() 38 | 39 | const host: typescript.CompilerHost = { 40 | ...typescript.createCompilerHost(options), 41 | writeFile: (fileName, content, ...args) => { 42 | fileMap.set(fileName, content) 43 | 44 | if (process.env.WRITE_TRANSFORMED_FILES) { 45 | return host.writeFile(fileName, content, ...args) 46 | } 47 | }, 48 | } 49 | 50 | let program = typescript.createProgram({ 51 | rootNames: getDirFiles(path.join(fixtureDir, 'src')), 52 | host, 53 | options, 54 | }) 55 | 56 | program = transformProgram(program, host, transformerOptions, { 57 | ts: typescript, 58 | }) 59 | 60 | program.emit() 61 | 62 | return { 63 | diagnostics: typescript.formatDiagnostics( 64 | program.getSemanticDiagnostics(), 65 | { 66 | getCurrentDirectory: () => fixtureDir, 67 | getCanonicalFileName: typescript.identity, 68 | getNewLine: () => '\n', 69 | } 70 | ), 71 | fileMap, 72 | fixtureDir, 73 | outDir, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tests/typescript-compiler-plugin.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { diff } from 'jest-diff' 4 | import { compile, getDirFiles } from './compile' 5 | 6 | const loadFixtureOptions = (fixtureDir: string) => { 7 | const filePath = path.resolve(fixtureDir, 'options.json') 8 | 9 | if (fs.existsSync(filePath)) { 10 | return JSON.parse(fs.readFileSync(filePath).toString()) 11 | } 12 | 13 | return {} 14 | } 15 | 16 | const loadCompilerOptions = (fixtureDir: string) => { 17 | const filePath = path.resolve(fixtureDir, 'compilerOptions.json') 18 | 19 | if (fs.existsSync(filePath)) { 20 | return JSON.parse(fs.readFileSync(filePath).toString()) 21 | } 22 | 23 | return {} 24 | } 25 | 26 | const getFixtures = () => { 27 | const directories = fs 28 | .readdirSync(path.resolve(__dirname, '__fixtures__'), { 29 | withFileTypes: true, 30 | }) 31 | .filter((entry) => entry.isDirectory()) 32 | 33 | return directories.map(({ name }) => [ 34 | name.replace(/-/g, ' '), 35 | name, 36 | loadFixtureOptions(path.join(__dirname, '__fixtures__', name)), 37 | loadCompilerOptions(path.join(__dirname, '__fixtures__', name)), 38 | ]) 39 | } 40 | 41 | describe('TypeScript Compiler plugin', () => { 42 | const fixtures = getFixtures() 43 | 44 | it.concurrent.each(fixtures)( 45 | '%s', 46 | async (_, fixture, pluginOptions, compilerOptions) => { 47 | const compiled = await compile(fixture, pluginOptions, compilerOptions) 48 | 49 | expect(compiled).toMatchOutput() 50 | expect(compiled).toMatchDiagnostics() 51 | } 52 | ) 53 | }) 54 | 55 | declare global { 56 | // eslint-disable-next-line @typescript-eslint/no-namespace 57 | namespace jest { 58 | interface Matchers { 59 | toMatchOutput(): Promise 60 | toMatchDiagnostics(): Promise 61 | } 62 | } 63 | } 64 | 65 | const loadExpectedDiagnostics = (fixtureDir: string) => { 66 | const filePath = path.resolve(fixtureDir, 'expected.diagnostics') 67 | 68 | if (fs.existsSync(filePath)) { 69 | return fs.readFileSync(filePath).toString() 70 | } 71 | 72 | return '' 73 | } 74 | 75 | expect.extend({ 76 | toMatchOutput(received: Awaited>) { 77 | const outFiles = getDirFiles(received.outDir) 78 | const expectedFiles = outFiles.sort().join('\n') 79 | const receivedFiles = [...received.fileMap.keys()].sort().join('\n') 80 | 81 | if (expectedFiles !== receivedFiles) { 82 | return { 83 | pass: false, 84 | message: () => 85 | `Distribution files are not equal.\n${diff( 86 | expectedFiles, 87 | receivedFiles 88 | )}`, 89 | } 90 | } 91 | 92 | for (const fileName of outFiles) { 93 | const expectedFileContent = fs.readFileSync(fileName).toString() 94 | const actualFileContent = received.fileMap.get(fileName) 95 | 96 | if (!actualFileContent) { 97 | throw new Error('file does not exist on filemap') 98 | } 99 | 100 | if (expectedFileContent !== actualFileContent) { 101 | const relativeFileName = fileName.replace( 102 | path.join(received.fixtureDir, path.sep, 'expected', path.sep), 103 | '' 104 | ) 105 | 106 | const difference = diff(expectedFileContent, actualFileContent) 107 | 108 | return { 109 | pass: false, 110 | message: () => 111 | `Outputs are different: ${relativeFileName}\n${difference}`, 112 | } 113 | } 114 | } 115 | 116 | return { 117 | pass: true, 118 | message: () => '', 119 | } 120 | }, 121 | toMatchDiagnostics(received: Awaited>) { 122 | const expected = loadExpectedDiagnostics(received.fixtureDir) 123 | 124 | return { 125 | pass: expected === received.diagnostics, 126 | message: () => 127 | `Diagnostics are not equal.\n${diff(expected, received.diagnostics)}`, 128 | } 129 | }, 130 | }) 131 | -------------------------------------------------------------------------------- /packages/compiler-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["./src"], 4 | "exclude": ["dist", "node_modules"], 5 | "compilerOptions": { 6 | "module": "CommonJS", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testMatch: ['/tests/**/*.test.ts'], 5 | testEnvironment: 'node', 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typescript-virtual-barrel/core", 3 | "version": "1.0.0", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "files": [ 7 | "dist/**" 8 | ], 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "tsc --watch", 13 | "lint": "TIMING=1 eslint src/**/*.ts* --fix", 14 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", 15 | "test": "jest" 16 | }, 17 | "devDependencies": { 18 | "@types/glob": "^7.2.0", 19 | "@types/jest": "^29.0.2", 20 | "@types/lodash": "^4.14.191", 21 | "@types/node": "^18.7.1", 22 | "eslint": "^7.32.0", 23 | "eslint-config-custom": "*", 24 | "jest": "^29.0.3", 25 | "ts-jest": "^29.0.1", 26 | "tsconfig": "*" 27 | }, 28 | "dependencies": { 29 | "camel-case": "^4.1.2", 30 | "glob": "^8.0.3", 31 | "typescript": "^5.4.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/src/calculateBarrel.ts: -------------------------------------------------------------------------------- 1 | import typescript, { factory } from 'typescript' 2 | import path from 'path' 3 | import { 4 | EntityWithIdentifier, 5 | ExportedEntity, 6 | getExportsOfSourceFile, 7 | } from './getExportsOfSourceFile' 8 | import { camelCase } from 'camel-case' 9 | import { getDirectoryFiles } from './getDirectoryFiles' 10 | import { isESModule, isNodeModernModuleResolution } from './utils' 11 | 12 | type Identifier = string 13 | 14 | export type ExportedEntities = Record 15 | 16 | const getEntityForExternalFile = (fileName: string) => { 17 | const fileNameWithExt = path.basename(fileName) 18 | const firstChar = fileNameWithExt.charAt(0) 19 | const camelCasedIdentifier = camelCase(fileNameWithExt) 20 | 21 | return { 22 | fileName: fileNameWithExt, 23 | identifier: 24 | firstChar === firstChar.toUpperCase() 25 | ? `${firstChar}${camelCasedIdentifier.substring(1)}` 26 | : camelCasedIdentifier, 27 | isDefaultExport: true, 28 | isTypeExport: false, 29 | } 30 | } 31 | 32 | const transformToExportDeclaration = ( 33 | { identifier, ...entity }: EntityWithIdentifier, 34 | compilerOptions: typescript.CompilerOptions 35 | ) => { 36 | const extension = typescript.extensionFromPath(entity.fileName) 37 | const fileNameWithoutExtension = path.basename(entity.fileName, extension) 38 | 39 | const isExtraneousExtension = !typescript.extensionIsTS(extension) 40 | 41 | const shouldAddAssertClause = 42 | isExtraneousExtension && isESModule(compilerOptions) 43 | 44 | const moduleSpecifier = isExtraneousExtension 45 | ? `./${entity.fileName}` 46 | : isNodeModernModuleResolution(compilerOptions) 47 | ? `./${fileNameWithoutExtension}${typescript.getOutputExtension( 48 | entity.fileName, 49 | compilerOptions 50 | )}` 51 | : `./${fileNameWithoutExtension}` 52 | 53 | return factory.createExportDeclaration( 54 | undefined, 55 | false, 56 | factory.createNamedExports([ 57 | factory.createExportSpecifier( 58 | false, 59 | entity.isDefaultExport ? 'default' : undefined, 60 | identifier 61 | ), 62 | ]), 63 | factory.createStringLiteral(moduleSpecifier), 64 | shouldAddAssertClause 65 | ? factory.createAssertClause( 66 | factory.createNodeArray([ 67 | factory.createAssertEntry( 68 | factory.createIdentifier('type'), 69 | factory.createStringLiteral(extension.substring(1)) 70 | ), 71 | ]), 72 | false 73 | ) 74 | : undefined 75 | ) 76 | } 77 | 78 | const transformEntitiesIntoExportDeclarations = ( 79 | entities: EntityWithIdentifier[], 80 | compilerOptions: typescript.CompilerOptions 81 | ) => { 82 | return typescript.OrganizeImports.coalesceExports( 83 | entities.map((entity) => 84 | transformToExportDeclaration(entity, compilerOptions) 85 | ), 86 | false 87 | ) 88 | } 89 | 90 | export const calculateBarrel = ( 91 | program: typescript.Program, 92 | dirName: string, 93 | readDirectory: typeof typescript.sys.readDirectory 94 | ) => { 95 | const checker = program.getTypeChecker() 96 | const compilerOptions = program.getCompilerOptions() 97 | 98 | const includedFiles: string[] = [] 99 | const diagnostics = new Map() 100 | 101 | const generateAdHocSourceFile = (fileName: string) => { 102 | const filePath = typescript.toPath( 103 | fileName, 104 | dirName, 105 | typescript.createGetCanonicalFileName(program.useCaseSensitiveFileNames()) 106 | ) 107 | 108 | const sourceFile = typescript.createSourceFile( 109 | fileName, 110 | program.readFile?.(filePath) ?? 'export {};', 111 | compilerOptions.target ?? typescript.ScriptTarget.ES3, 112 | true 113 | ) 114 | 115 | typescript.bindSourceFile(sourceFile, compilerOptions) 116 | 117 | return sourceFile 118 | } 119 | 120 | const findEntitiesOfFile = (fileName: string) => { 121 | const sourceFile = 122 | program.getSourceFile(fileName) ?? generateAdHocSourceFile(fileName) 123 | 124 | const result = getExportsOfSourceFile({ 125 | sourceFile, 126 | checker, 127 | }) 128 | 129 | if (result === null) { 130 | includedFiles.push(fileName) 131 | 132 | // For the time being, "external file" = JSON file. 133 | return [getEntityForExternalFile(fileName)] 134 | } 135 | 136 | if (result.entities.length > 0) { 137 | includedFiles.push(sourceFile.fileName) 138 | } 139 | 140 | if (result.diagnostics.length > 0) { 141 | diagnostics.set(sourceFile.fileName, result.diagnostics) 142 | } 143 | 144 | return result.entities 145 | } 146 | 147 | const barrelEntitiesGroupedByOrigin: EntityWithIdentifier[][] = 148 | getDirectoryFiles(dirName, program, readDirectory).map(findEntitiesOfFile) 149 | 150 | const statements = barrelEntitiesGroupedByOrigin 151 | .map((barrelEntities) => 152 | transformEntitiesIntoExportDeclarations(barrelEntities, compilerOptions) 153 | ) 154 | .flat() 155 | 156 | const sourceFile = factory.createSourceFile( 157 | statements, 158 | factory.createToken(typescript.SyntaxKind.EndOfFileToken), 159 | typescript.NodeFlags.None 160 | ) 161 | 162 | return { 163 | sourceFile, 164 | includedFiles, 165 | diagnostics, 166 | barrelEntities: barrelEntitiesGroupedByOrigin 167 | .flat() 168 | .reduce((statements, curr) => { 169 | statements[curr.identifier] = curr 170 | 171 | return statements 172 | }, {}), 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/core/src/getDirectoryFiles.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import typescript from 'typescript' 3 | 4 | const isInsideBarrelFolder = (dirName: string) => (fileName: string) => 5 | path.dirname(fileName) === dirName 6 | 7 | export const getDirectoryFiles = ( 8 | dirName: string, 9 | program: typescript.Program, 10 | readDirectory: typeof typescript.sys.readDirectory 11 | ) => { 12 | const extensions = typescript 13 | .getSupportedExtensionsWithJsonIfResolveJsonModule( 14 | program?.getCompilerOptions(), 15 | typescript.getSupportedExtensions(program?.getCompilerOptions()) 16 | ) 17 | .flat() 18 | 19 | return readDirectory(dirName, extensions).filter( 20 | isInsideBarrelFolder(dirName) 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/getExportsOfSourceFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import typescript from 'typescript' 3 | 4 | type FileName = string 5 | 6 | const getNoIdentifierDiagnostic = ( 7 | sourceFile: typescript.SourceFile, 8 | declaration: typescript.Declaration 9 | ) => { 10 | return typescript.createFileDiagnostic( 11 | sourceFile, 12 | declaration.getStart(), 13 | declaration.getWidth(), 14 | { 15 | key: declaration.getText(), 16 | category: typescript.DiagnosticCategory.Warning, 17 | code: 9999, 18 | message: 19 | 'Missing identifier for export. This member will not be included in the barrel.', 20 | } 21 | ) 22 | } 23 | 24 | export interface ExportedEntity { 25 | fileName: FileName 26 | isDefaultExport: boolean 27 | isTypeExport: boolean 28 | } 29 | 30 | export type EntityWithIdentifier = ExportedEntity & { identifier: string } 31 | 32 | type GetExportsOfSourceFileResult = { 33 | entities: EntityWithIdentifier[] 34 | diagnostics: typescript.DiagnosticWithLocation[] 35 | } 36 | 37 | type Options = { 38 | sourceFile: typescript.SourceFile 39 | checker: typescript.TypeChecker 40 | } 41 | 42 | export const getExportsOfSourceFile = ({ sourceFile, checker }: Options) => { 43 | const result: GetExportsOfSourceFileResult = { 44 | entities: [], 45 | diagnostics: [], 46 | } 47 | 48 | const supportedExtensions = typescript.getSupportedExtensions().flat() 49 | 50 | const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile) 51 | 52 | const isTsFile = supportedExtensions.includes( 53 | path.extname(sourceFile.fileName) as typescript.Extension 54 | ) 55 | 56 | /** 57 | * Not a valid module. 58 | */ 59 | if (!sourceFileSymbol && !isTsFile) { 60 | return null 61 | } 62 | 63 | sourceFileSymbol?.exports?.forEach((symbol) => { 64 | /** 65 | * The symbol doesn't have a declaration. 66 | */ 67 | if (!symbol.declarations || symbol.declarations.length === 0) { 68 | return 69 | } 70 | 71 | const [declaration] = symbol.declarations 72 | 73 | const identifier = typescript.getNameOfDeclaration(declaration)?.getText() 74 | 75 | /** 76 | * If no identifier was found, let's make the developer aware of it. 77 | */ 78 | if (!identifier) { 79 | result.diagnostics.push( 80 | getNoIdentifierDiagnostic(sourceFile, declaration) 81 | ) 82 | 83 | return 84 | } 85 | 86 | const isTypeExport = 87 | typescript.isInterfaceDeclaration(declaration) || 88 | typescript.isTypeAliasDeclaration(declaration) 89 | 90 | result.entities.push({ 91 | fileName: path.basename(sourceFile.fileName), 92 | isDefaultExport: symbol.escapedName === 'default', 93 | isTypeExport, 94 | identifier, 95 | }) 96 | }) 97 | 98 | return result 99 | } 100 | -------------------------------------------------------------------------------- /packages/core/src/getFoldersWithoutIndexFile.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | import path from 'path' 3 | 4 | export const getFoldersWithoutIndexFile = (includedFileNames: string[]) => { 5 | const foldersWithoutIndexFile = new Set() 6 | 7 | for (const fileName of includedFileNames) { 8 | const folderName = path.dirname(fileName) 9 | const indexFileName = typescript.combinePaths(folderName, 'index.ts') 10 | 11 | if (!includedFileNames.includes(indexFileName)) { 12 | foldersWithoutIndexFile.add(folderName) 13 | } 14 | } 15 | 16 | return Array.from(foldersWithoutIndexFile) 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './calculateBarrel' 2 | export * from './getExportsOfSourceFile' 3 | export * from './getDirectoryFiles' 4 | export * from './getFoldersWithoutIndexFile' 5 | export * from './patchMethod' 6 | export * from './utils' 7 | -------------------------------------------------------------------------------- /packages/core/src/patchMethod.ts: -------------------------------------------------------------------------------- 1 | export const patchMethod = , M extends keyof O>( 2 | object: O, 3 | method: M, 4 | callback: (original: O[M], ...args: Parameters) => ReturnType 5 | ) => { 6 | const original = object[method].bind(object) as O[M] 7 | 8 | const patched = (...args: Parameters) => { 9 | return callback(original, ...args) 10 | } 11 | 12 | object[method] = patched as O[M] 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | 3 | export const isESModule = (compilerOptions: typescript.CompilerOptions) => 4 | compilerOptions.module && 5 | compilerOptions.module >= typescript.ModuleKind.ES2015 6 | 7 | export const isNodeModernModuleResolution = ( 8 | compilerOptions: typescript.CompilerOptions 9 | ) => 10 | compilerOptions.moduleResolution && 11 | compilerOptions.moduleResolution >= typescript.ModuleResolutionKind.Node16 12 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/calculateBarrel.test.ts: -------------------------------------------------------------------------------- 1 | import { getDirectoryFiles } from './../../src/getDirectoryFiles' 2 | import { calculateBarrel } from '../../src/calculateBarrel' 3 | import typescript from 'typescript' 4 | 5 | const MODULES_FOLDER = typescript.combinePaths(__dirname, 'program', 'folder') 6 | 7 | const printer = typescript.createPrinter() 8 | 9 | describe('calculateBarrel', () => { 10 | let barrel: ReturnType 11 | 12 | const printStatement = (statementIndex: number) => { 13 | return printer.printNode( 14 | typescript.EmitHint.Unspecified, 15 | barrel.sourceFile.statements[statementIndex], 16 | barrel.sourceFile 17 | ) 18 | } 19 | 20 | describe('ES modules', () => { 21 | beforeAll(() => { 22 | const program = typescript.createProgram( 23 | getDirectoryFiles( 24 | MODULES_FOLDER, 25 | typescript.createProgram([], { 26 | resolveJsonModule: true, 27 | }), 28 | typescript.sys.readDirectory 29 | ), 30 | { 31 | resolveJsonModule: true, 32 | module: typescript.ModuleKind.ESNext, 33 | moduleResolution: typescript.ModuleResolutionKind.NodeNext, 34 | } 35 | ) 36 | 37 | barrel = calculateBarrel( 38 | program, 39 | MODULES_FOLDER, 40 | typescript.sys.readDirectory 41 | ) 42 | }) 43 | 44 | it('adds output extension for typescript imports', () => { 45 | expect(printStatement(0)).toEqual( 46 | 'export { default as NamedDefaultExport } from "./0-aliases-default-export.js";' 47 | ) 48 | }) 49 | 50 | it('adds assert block after extraneous imports', () => { 51 | expect(printStatement(barrel.sourceFile.statements.length - 1)).toEqual( 52 | 'export { default as zForwardsJson } from "./z-forwards.json" assert { type: "json" };' 53 | ) 54 | }) 55 | }) 56 | 57 | describe('symbol forwarding', () => { 58 | beforeAll(() => { 59 | const program = typescript.createProgram( 60 | getDirectoryFiles( 61 | MODULES_FOLDER, 62 | typescript.createProgram([], { 63 | resolveJsonModule: true, 64 | }), 65 | typescript.sys.readDirectory 66 | ), 67 | { 68 | resolveJsonModule: true, 69 | } 70 | ) 71 | 72 | barrel = calculateBarrel( 73 | program, 74 | MODULES_FOLDER, 75 | typescript.sys.readDirectory 76 | ) 77 | }) 78 | 79 | it('aliases default export', () => { 80 | expect(printStatement(0)).toEqual( 81 | 'export { default as NamedDefaultExport } from "./0-aliases-default-export";' 82 | ) 83 | 84 | expect(barrel.includedFiles.includes('./0-aliases-default-export')) 85 | }) 86 | 87 | it('forwards member export', () => { 88 | expect(printStatement(1)).toEqual( 89 | 'export { VariableExport } from "./1-forwards-member-export";' 90 | ) 91 | 92 | expect(barrel.includedFiles.includes('./1-forwards-member-export')) 93 | }) 94 | 95 | it('forwards named export', () => { 96 | expect(printStatement(2)).toEqual( 97 | 'export { NamedExport } from "./2-forwards-named-export";' 98 | ) 99 | 100 | expect(barrel.includedFiles.includes('./2-forwards-named-export')) 101 | }) 102 | 103 | it('forwards aliased named export', () => { 104 | expect(printStatement(3)).toEqual( 105 | 'export { AliasedNamedExport } from "./3-forwards-aliased-named-export";' 106 | ) 107 | 108 | expect(barrel.includedFiles.includes('./3-forwards-aliased-named-export')) 109 | }) 110 | 111 | it('forwards multiple named exports per file', () => { 112 | expect(printStatement(4)).toEqual( 113 | 'export { AliasedMember2, default as DefaultFunction, Member1, VariableMember } from "./4-forwards-multiple-exports";' 114 | ) 115 | 116 | expect(barrel.includedFiles.includes('./4-forwards-multiple-exports')) 117 | }) 118 | 119 | it('forwards namespace export', () => { 120 | expect(printStatement(5)).toEqual( 121 | 'export { NamespaceExport } from "./5-forwards-namespace-export";' 122 | ) 123 | }) 124 | 125 | it('forwards type declarations', () => { 126 | expect(printStatement(6)).toEqual( 127 | 'export { AliasedMyType, ExportedType, MyEnum, MyInterface, MyType } from "./6-forwards-type-declarations";' 128 | ) 129 | 130 | expect(barrel.includedFiles.includes('./6-forwards-type-declarations.ts')) 131 | }) 132 | 133 | it('forwards json files', () => { 134 | expect(printStatement(barrel.sourceFile.statements.length - 2)).toEqual( 135 | 'export { default as YForwardsJson } from "./Y-forwards.json";' 136 | ) 137 | 138 | expect(printStatement(barrel.sourceFile.statements.length - 1)).toEqual( 139 | 'export { default as zForwardsJson } from "./z-forwards.json";' 140 | ) 141 | 142 | expect(barrel.includedFiles.includes('./y-forwards.json')) 143 | expect(barrel.includedFiles.includes('./z-forwards.json')) 144 | }) 145 | }) 146 | 147 | describe('diagnostics', () => { 148 | beforeAll(() => { 149 | const program = typescript.createProgram( 150 | getDirectoryFiles( 151 | MODULES_FOLDER, 152 | typescript.createProgram([], { 153 | resolveJsonModule: true, 154 | }), 155 | typescript.sys.readDirectory 156 | ), 157 | { 158 | resolveJsonModule: true, 159 | } 160 | ) 161 | 162 | barrel = calculateBarrel( 163 | program, 164 | MODULES_FOLDER, 165 | typescript.sys.readDirectory 166 | ) 167 | }) 168 | 169 | it('does not include unnamed default export', () => { 170 | const unnamedDefaultExportDiagnostic = barrel.diagnostics.get( 171 | typescript.combinePaths(MODULES_FOLDER, 'unnamed-default-export.ts') 172 | ) 173 | 174 | expect(unnamedDefaultExportDiagnostic).toBeDefined() 175 | expect(unnamedDefaultExportDiagnostic).toEqual([ 176 | expect.objectContaining({ 177 | category: typescript.DiagnosticCategory.Warning, 178 | code: 9999, 179 | start: 65, 180 | length: 29, 181 | messageText: 182 | 'Missing identifier for export. This member will not be included in the barrel.', 183 | }), 184 | ]) 185 | }) 186 | 187 | it('does not include unnamed namespace export', () => { 188 | const unnamedDefaultExportDiagnostic = barrel.diagnostics.get( 189 | typescript.combinePaths(MODULES_FOLDER, 'unnamed-namespace-export.ts') 190 | ) 191 | 192 | expect(unnamedDefaultExportDiagnostic).toBeDefined() 193 | expect(unnamedDefaultExportDiagnostic).toEqual([ 194 | expect.objectContaining({ 195 | category: typescript.DiagnosticCategory.Warning, 196 | code: 9999, 197 | start: 0, 198 | length: 42, 199 | messageText: 200 | 'Missing identifier for export. This member will not be included in the barrel.', 201 | }), 202 | ]) 203 | }) 204 | }) 205 | }) 206 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/0-aliases-default-export.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-function 2 | export default function NamedDefaultExport() {} 3 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/1-forwards-member-export.ts: -------------------------------------------------------------------------------- 1 | export const VariableExport = true 2 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/2-forwards-named-export.ts: -------------------------------------------------------------------------------- 1 | const NamedExport = true 2 | 3 | export { NamedExport } 4 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/3-forwards-aliased-named-export.ts: -------------------------------------------------------------------------------- 1 | const NamedExport = true 2 | 3 | export { NamedExport as AliasedNamedExport } 4 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/4-forwards-multiple-exports.ts: -------------------------------------------------------------------------------- 1 | export const VariableMember = true 2 | 3 | const Member1 = true 4 | const Member2 = true 5 | 6 | export { Member1, Member2 as AliasedMember2 } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-empty-function 9 | export default function DefaultFunction() {} 10 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/5-forwards-namespace-export.ts: -------------------------------------------------------------------------------- 1 | export * as NamespaceExport from './1-forwards-member-export' 2 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/6-forwards-type-declarations.ts: -------------------------------------------------------------------------------- 1 | type MyType = boolean 2 | 3 | export enum MyEnum {} 4 | export { MyType } 5 | export { MyType as AliasedMyType } 6 | 7 | export interface MyInterface { 8 | value: unknown 9 | } 10 | 11 | export type ExportedType = boolean 12 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/Y-forwards.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/unnamed-default-export.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-function 2 | export default function () {} 3 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/unnamed-namespace-export.ts: -------------------------------------------------------------------------------- 1 | export * from './0-aliases-default-export' 2 | -------------------------------------------------------------------------------- /packages/core/tests/calculateBarrel/program/folder/z-forwards.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/getDirectoryFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { getDirectoryFiles } from '../../src/getDirectoryFiles' 2 | import { createProgram, combinePaths, sys } from 'typescript' 3 | 4 | const PROGRAM_DIR = combinePaths(__dirname, 'program') 5 | 6 | describe('getDirectoryFiles', () => { 7 | it('returns all files in the specified folder', () => { 8 | expect( 9 | getDirectoryFiles( 10 | combinePaths(PROGRAM_DIR, 'folder'), 11 | createProgram([], {}), 12 | sys.readDirectory 13 | ) 14 | ).toEqual([ 15 | combinePaths(PROGRAM_DIR, 'folder', 'a.ts'), 16 | combinePaths(PROGRAM_DIR, 'folder', 'b.ts'), 17 | combinePaths(PROGRAM_DIR, 'folder', 'c.ts'), 18 | ]) 19 | }) 20 | 21 | it('does not return files from other folders', () => { 22 | const directoryFiles = getDirectoryFiles( 23 | combinePaths(PROGRAM_DIR, 'folder'), 24 | createProgram([], {}), 25 | sys.readDirectory 26 | ) 27 | 28 | expect(directoryFiles).toEqual([ 29 | combinePaths(PROGRAM_DIR, 'folder', 'a.ts'), 30 | combinePaths(PROGRAM_DIR, 'folder', 'b.ts'), 31 | combinePaths(PROGRAM_DIR, 'folder', 'c.ts'), 32 | ]) 33 | 34 | expect(directoryFiles).not.toContain( 35 | combinePaths(PROGRAM_DIR, 'another-folder', 'a.ts') 36 | ) 37 | }) 38 | 39 | it('returns non-ts extensions if specified in the program', () => { 40 | expect( 41 | getDirectoryFiles( 42 | combinePaths(PROGRAM_DIR, 'folder'), 43 | createProgram([], { resolveJsonModule: true }), 44 | sys.readDirectory 45 | ) 46 | ).toEqual([ 47 | combinePaths(PROGRAM_DIR, 'folder', 'a.ts'), 48 | combinePaths(PROGRAM_DIR, 'folder', 'b.ts'), 49 | combinePaths(PROGRAM_DIR, 'folder', 'c.ts'), 50 | combinePaths(PROGRAM_DIR, 'folder', 'file.json'), 51 | ]) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/another-folder/a.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/another-folder/b.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/another-folder/c.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/folder/a.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/folder/b.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/folder/c.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /packages/core/tests/getDirectoryFiles/program/folder/file.json: -------------------------------------------------------------------------------- 1 | { "ok": true } 2 | -------------------------------------------------------------------------------- /packages/core/tests/getExportsOfSourceFile.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { 3 | createProgram, 4 | combinePaths, 5 | createCompilerHost, 6 | createSourceFile, 7 | ScriptTarget, 8 | DiagnosticCategory, 9 | } from 'typescript' 10 | import { getExportsOfSourceFile } from '../src/getExportsOfSourceFile' 11 | 12 | const getExports = ( 13 | fileName: string, 14 | content: string, 15 | additionalFiles: Map = new Map() 16 | ) => { 17 | const program = createProgram({ 18 | rootNames: [fileName, ...additionalFiles.keys()], 19 | options: {}, 20 | host: { 21 | ...createCompilerHost({}), 22 | fileExists: (path: string) => 23 | additionalFiles.has(path) || path === fileName, 24 | readFile: (path: string) => additionalFiles.get(path) ?? content, 25 | getSourceFile: (path: string) => { 26 | if (path === fileName || additionalFiles.has(path)) { 27 | return createSourceFile( 28 | path, 29 | additionalFiles.get(path) ?? content, 30 | ScriptTarget.ESNext 31 | ) 32 | } 33 | }, 34 | }, 35 | }) 36 | 37 | return getExportsOfSourceFile({ 38 | sourceFile: program.getSourceFile(fileName)!, 39 | checker: program.getTypeChecker(), 40 | }) 41 | } 42 | 43 | describe('getExportsOfSourceFile', () => { 44 | describe('ECMAScript exports', () => { 45 | it('parses a default export', () => { 46 | const actual = getExports( 47 | combinePaths(__dirname, 'default-export.ts'), 48 | `export default function MyFunction() {}` 49 | ) 50 | 51 | const expected: typeof actual = { 52 | diagnostics: [], 53 | entities: [ 54 | { 55 | fileName: 'default-export.ts', 56 | identifier: 'MyFunction', 57 | isDefaultExport: true, 58 | isTypeExport: false, 59 | }, 60 | ], 61 | } 62 | 63 | expect(actual).toEqual(expected) 64 | }) 65 | 66 | it('parses a default export reference', () => { 67 | const actual = getExports( 68 | combinePaths(__dirname, 'default-export-reference.ts'), 69 | `function ReferenceToMyFunction() {}; 70 | export default ReferenceToMyFunction` 71 | ) 72 | 73 | const expected: typeof actual = { 74 | diagnostics: [], 75 | entities: [ 76 | { 77 | fileName: 'default-export-reference.ts', 78 | identifier: 'ReferenceToMyFunction', 79 | isDefaultExport: true, 80 | isTypeExport: false, 81 | }, 82 | ], 83 | } 84 | 85 | expect(actual).toEqual(expected) 86 | }) 87 | 88 | it('parses a member export', () => { 89 | const actual = getExports( 90 | combinePaths(__dirname, 'member-export.ts'), 91 | `export const variable = () => {}` 92 | ) 93 | 94 | const expected: typeof actual = { 95 | diagnostics: [], 96 | entities: [ 97 | { 98 | fileName: 'member-export.ts', 99 | identifier: 'variable', 100 | isDefaultExport: false, 101 | isTypeExport: false, 102 | }, 103 | ], 104 | } 105 | 106 | expect(actual).toEqual(expected) 107 | }) 108 | 109 | it('parses named export', () => { 110 | const actual = getExports( 111 | combinePaths(__dirname, 'named-export.ts'), 112 | `const namedVariable = () => {}; 113 | export { namedVariable } 114 | ` 115 | ) 116 | 117 | const expected: typeof actual = { 118 | diagnostics: [], 119 | entities: [ 120 | { 121 | fileName: 'named-export.ts', 122 | identifier: 'namedVariable', 123 | isDefaultExport: false, 124 | isTypeExport: false, 125 | }, 126 | ], 127 | } 128 | 129 | expect(actual).toEqual(expected) 130 | }) 131 | 132 | it('parses an aliased named export', () => { 133 | const actual = getExports( 134 | combinePaths(__dirname, 'aliased-named-export.ts'), 135 | `const namedVariable = () => {}; 136 | export { namedVariable as anotherIdentifier } 137 | ` 138 | ) 139 | 140 | const expected: typeof actual = { 141 | diagnostics: [], 142 | entities: [ 143 | { 144 | fileName: 'aliased-named-export.ts', 145 | identifier: 'anotherIdentifier', 146 | isDefaultExport: false, 147 | isTypeExport: false, 148 | }, 149 | ], 150 | } 151 | 152 | expect(actual).toEqual(expected) 153 | }) 154 | 155 | it('parses multiple named exports', () => { 156 | const actual = getExports( 157 | combinePaths(__dirname, 'multiple-named-exports.ts'), 158 | `const namedVariable = () => {}; 159 | export { namedVariable, namedVariable as anotherIdentifier } 160 | ` 161 | ) 162 | 163 | const expected: typeof actual = { 164 | diagnostics: [], 165 | entities: [ 166 | { 167 | fileName: 'multiple-named-exports.ts', 168 | identifier: 'namedVariable', 169 | isDefaultExport: false, 170 | isTypeExport: false, 171 | }, 172 | { 173 | fileName: 'multiple-named-exports.ts', 174 | identifier: 'anotherIdentifier', 175 | isDefaultExport: false, 176 | isTypeExport: false, 177 | }, 178 | ], 179 | } 180 | 181 | expect(actual).toEqual(expected) 182 | }) 183 | 184 | it('parses a default exported enum', () => { 185 | const actual = getExports( 186 | combinePaths(__dirname, 'export-default-enum.ts'), 187 | `enum MyEnum {}; 188 | export default MyEnum 189 | ` 190 | ) 191 | 192 | const expected: typeof actual = { 193 | diagnostics: [], 194 | entities: [ 195 | { 196 | fileName: 'export-default-enum.ts', 197 | identifier: 'MyEnum', 198 | isDefaultExport: true, 199 | isTypeExport: false, 200 | }, 201 | ], 202 | } 203 | 204 | expect(actual).toEqual(expected) 205 | }) 206 | }) 207 | 208 | describe('type exports', () => { 209 | it('parses an exported interface', () => { 210 | const actual = getExports( 211 | combinePaths(__dirname, 'exported-interface.ts'), 212 | `export interface MyInterface {}` 213 | ) 214 | 215 | const expected: typeof actual = { 216 | diagnostics: [], 217 | entities: [ 218 | { 219 | fileName: 'exported-interface.ts', 220 | identifier: 'MyInterface', 221 | isDefaultExport: false, 222 | isTypeExport: true, 223 | }, 224 | ], 225 | } 226 | 227 | expect(actual).toEqual(expected) 228 | }) 229 | }) 230 | 231 | describe('diagnostic reporting', () => { 232 | it('warns if a default export has no identifier', () => { 233 | const actual = getExports( 234 | combinePaths(__dirname, 'default-export-without-identifier.ts'), 235 | `export default function() {}` 236 | ) 237 | 238 | const expected: typeof actual = { 239 | diagnostics: [ 240 | { 241 | category: DiagnosticCategory.Warning, 242 | code: 9999, 243 | file: expect.anything(), 244 | start: 0, 245 | length: 28, 246 | messageText: 247 | 'Missing identifier for export. This member will not be included in the barrel.', 248 | }, 249 | ], 250 | entities: [], 251 | } 252 | 253 | expect(actual).toEqual(expected) 254 | }) 255 | 256 | it('warns if a namespace export has no identifier', () => { 257 | const actual = getExports( 258 | combinePaths(__dirname, 'namespace-export-without-identifier.ts'), 259 | `export * from './file'`, 260 | new Map([ 261 | [combinePaths(__dirname, './file.ts'), `export const var1 = true`], 262 | ]) 263 | ) 264 | 265 | const expected: typeof actual = { 266 | diagnostics: [ 267 | { 268 | category: DiagnosticCategory.Warning, 269 | code: 9999, 270 | file: expect.anything(), 271 | start: 0, 272 | length: 22, 273 | messageText: 274 | 'Missing identifier for export. This member will not be included in the barrel.', 275 | }, 276 | ], 277 | entities: [], 278 | } 279 | 280 | expect(actual).toEqual(expected) 281 | }) 282 | }) 283 | }) 284 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["./src"], 4 | "exclude": ["dist", "node_modules"], 5 | "compilerOptions": { 6 | "module": "CommonJS", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 3 | plugins: ['@typescript-eslint', 'prettier'], 4 | env: { 5 | node: true, 6 | jest: true, 7 | es6: true, 8 | }, 9 | rules: { 10 | 'prettier/prettier': 'error', 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | } 14 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "eslint": "^7.23.0", 8 | "eslint-config-prettier": "^8.3.0", 9 | "eslint-config-turbo": "latest" 10 | }, 11 | "devDependencies": { 12 | "@typescript-eslint/eslint-plugin": "^6.21.0", 13 | "eslint-plugin-prettier": "^4.2.1", 14 | "typescript": "^5.4.2" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/install/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "@typescript-virtual-barrel/install", 4 | "version": "1.0.0", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "bin": { 8 | "@typescript-virtual-barrel/install": "./dist/index.js" 9 | }, 10 | "files": [ 11 | "dist/**" 12 | ], 13 | "license": "MIT", 14 | "scripts": { 15 | "build": "tsc", 16 | "dev": "tsc --watch", 17 | "lint": "TIMING=1 eslint src/**/*.ts* --fix", 18 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^18.7.1", 22 | "eslint": "^7.32.0", 23 | "eslint-config-custom": "*", 24 | "tsconfig": "*" 25 | }, 26 | "dependencies": { 27 | "comment-json": "^4.2.3", 28 | "ora": "^6.3.1", 29 | "typescript": "^5.4.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/install/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { PackageJson, TsConfigJson, JsonObject } from 'type-fest' 3 | import { fileExists, determineTSVersion, modifyJSON, run } from './utils.js' 4 | import fs from 'fs/promises' 5 | import path from 'path' 6 | import ora from 'ora' 7 | 8 | console.log(`\t 9 | \tTypeScript Virtual Barrel 🛢️ 10 | `) 11 | 12 | const spinner = ora() 13 | 14 | spinner.start('Installing @typescript-virtual-barrel') 15 | 16 | const cwd = process.cwd() 17 | 18 | const isYarn = await fileExists('yarn.lock') 19 | const isNPMProject = await fileExists('package.json') 20 | const isTypeScriptProject = await fileExists('tsconfig.json') 21 | 22 | if (!isNPMProject) { 23 | spinner.fail('No package.json file found') 24 | process.exit(1) 25 | } 26 | 27 | spinner.info('package.json file found') 28 | 29 | if (!isTypeScriptProject) { 30 | spinner.fail('No tsconfig.json file found') 31 | process.exit(1) 32 | } 33 | 34 | spinner.info('tsconfig.json file found') 35 | 36 | const typeScriptVersion = await determineTSVersion() 37 | 38 | if (!typeScriptVersion) { 39 | spinner.fail( 40 | 'Failed to determine TypeScript version. Please make sure you ran npm/yarn install first.' 41 | ) 42 | process.exit(1) 43 | } 44 | 45 | spinner.start('Modifying package.json') 46 | 47 | await modifyJSON(['package.json'], (packageJson) => { 48 | if (!packageJson.scripts) { 49 | packageJson.scripts = {} 50 | } 51 | 52 | if (!packageJson.scripts.prepare) { 53 | packageJson.scripts.prepare = 'ts-patch install' 54 | } else if (!packageJson.scripts.prepare.includes('ts-patch')) { 55 | packageJson.scripts.prepare = '; ts-patch install' 56 | } 57 | }) 58 | 59 | spinner.succeed('prepare script added to package.json') 60 | 61 | spinner.start('Adding VSCode settings') 62 | 63 | await fs.mkdir(path.join(process.cwd(), '.vscode'), { 64 | recursive: true, 65 | }) 66 | 67 | await modifyJSON(['.vscode', 'settings.json'], (content) => { 68 | content['typescript.tsdk'] = 'node_modules/typescript/lib' 69 | }) 70 | 71 | spinner.succeed('.vscode/settings.json file modified') 72 | 73 | spinner.start('Adding plugins to tsconfig.json') 74 | 75 | await modifyJSON(['tsconfig.json'], (tsConfig) => { 76 | if (!tsConfig.compilerOptions) { 77 | tsConfig.compilerOptions = {} 78 | } 79 | 80 | tsConfig.compilerOptions.plugins = [ 81 | { 82 | transform: '@typescript-virtual-barrel/compiler-plugin', 83 | transformProgram: true, 84 | }, 85 | { 86 | name: '@typescript-virtual-barrel/language-service-plugin', 87 | }, 88 | ...(tsConfig.compilerOptions.plugins ?? []), 89 | ] 90 | }) 91 | 92 | spinner.succeed('Plugins added to tsconfig.json') 93 | 94 | spinner.start('Installing dependencies\n') 95 | 96 | const packages = typeScriptVersion.startsWith('5') 97 | ? [ 98 | 'ts-patch@^3', 99 | '@typescript-virtual-barrel/compiler-plugin@latest', 100 | '@typescript-virtual-barrel/language-service-plugin@latest', 101 | ] 102 | : [ 103 | 'ts-patch@^2', 104 | '@typescript-virtual-barrel/compiler-plugin@^0.0.3', 105 | '@typescript-virtual-barrel/language-service-plugin@^0.0.7', 106 | ] 107 | 108 | const installCommand = isYarn ? 'add' : 'install' 109 | const packageManager = isYarn ? 'yarn' : 'npm' 110 | 111 | try { 112 | await run(packageManager, [installCommand, ...packages, '-D'], { 113 | cwd, 114 | }) 115 | 116 | spinner.succeed('Dependencies installed') 117 | } catch { 118 | spinner.fail('Failed to install dependencies') 119 | process.exit(1) 120 | } 121 | 122 | spinner.start('Patching TypeScript') 123 | 124 | try { 125 | await run(packageManager, isYarn ? ['prepare'] : ['run', 'prepare'], { 126 | cwd, 127 | }) 128 | 129 | spinner.succeed('TypeScript patched') 130 | } catch { 131 | spinner.fail('Failed to patch TypeScript') 132 | } 133 | 134 | spinner.succeed('TypeScript Virtual Barrel has been successfully configured 🎉') 135 | -------------------------------------------------------------------------------- /packages/install/src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import path from 'path' 3 | import { PackageJson } from 'type-fest' 4 | import { parse, stringify } from 'comment-json' 5 | import { spawn, SpawnOptions } from 'node:child_process' 6 | 7 | export const run = ( 8 | command: string, 9 | arguments_: string[], 10 | options: SpawnOptions = {} 11 | ) => { 12 | return new Promise((resolve, reject) => { 13 | const child = spawn(command, arguments_, { 14 | ...options, 15 | stdio: 'inherit', 16 | }) 17 | 18 | child.on('error', (e) => { 19 | console.log(e) 20 | reject(e) 21 | }) 22 | 23 | child.on('close', (code) => { 24 | if (code !== 0) { 25 | reject({ 26 | command: `${command} ${arguments_.join(' ')}`, 27 | code, 28 | }) 29 | return 30 | } 31 | resolve() 32 | }) 33 | }) 34 | } 35 | 36 | export const fileExists = (fileName: string) => 37 | fs 38 | .stat(path.resolve(process.cwd(), fileName)) 39 | .then(() => true) 40 | .catch(() => false) 41 | 42 | export const writeFile = (fileName: string, content: string) => 43 | fs.writeFile(path.resolve(process.cwd(), fileName), content) 44 | 45 | export const modifyJSON = async ( 46 | fileName: string[], 47 | callback: (content: T) => void 48 | ) => { 49 | const filePath = path.join(process.cwd(), ...fileName) 50 | 51 | const content = await fs 52 | .readFile(filePath, 'utf-8') 53 | .then((value) => value) 54 | .catch(() => '{}') 55 | 56 | const parsedContent = parse(content) 57 | callback(parsedContent as T) 58 | 59 | await fs.writeFile(filePath, stringify(parsedContent, null, 2)) 60 | } 61 | 62 | export const determineTSVersion = async () => { 63 | const tsPackageJson = path.join( 64 | process.cwd(), 65 | 'node_modules', 66 | 'typescript', 67 | 'package.json' 68 | ) 69 | 70 | if (!(await fileExists(tsPackageJson))) { 71 | return 72 | } 73 | 74 | const { default: packageJson } = await import(tsPackageJson, { 75 | assert: { type: 'json' }, 76 | }) 77 | 78 | return (packageJson as PackageJson).version 79 | } 80 | -------------------------------------------------------------------------------- /packages/install/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["./src"], 4 | "exclude": ["dist", "node_modules"], 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/language-service-plugin/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testMatch: ['/tests/**/*.test.ts'], 5 | testEnvironment: 'node', 6 | } 7 | -------------------------------------------------------------------------------- /packages/language-service-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typescript-virtual-barrel/language-service-plugin", 3 | "version": "1.0.0", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "files": [ 7 | "dist/**" 8 | ], 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "tsc --watch", 13 | "lint": "TIMING=1 eslint src/**/*.ts* --fix", 14 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", 15 | "pretest": "echo 'Did you link this package within root node_modules before testing? Otherwise, the tests will fail.'", 16 | "test": "jest" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^29.0.2", 20 | "@types/node": "^18.7.1", 21 | "eslint": "^7.32.0", 22 | "eslint-config-custom": "*", 23 | "jest": "^29.0.3", 24 | "ts-jest": "^29.0.1", 25 | "tsconfig": "*" 26 | }, 27 | "dependencies": { 28 | "@typescript-virtual-barrel/core": ">=1.0.0", 29 | "typescript": "^5.4.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/createBarrelRegistry.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | 3 | type FileName = string 4 | type Version = string 5 | 6 | type BarrelGraph = Record 7 | 8 | export type BarrelCache = Map 9 | 10 | interface UpsertBarrelParameters { 11 | barrelName: string 12 | includedFiles: string[] 13 | } 14 | 15 | export type UpsertBarrel = (parameters: UpsertBarrelParameters) => void 16 | 17 | type CreateBarrelRegistryParameters = { 18 | projectFolders: string[] 19 | getModuleVersion: (fileName: string) => string | undefined 20 | } 21 | 22 | export const createBarrelRegistry = ({ 23 | projectFolders, 24 | getModuleVersion, 25 | }: CreateBarrelRegistryParameters) => { 26 | const createdBarrels: BarrelCache = new Map() 27 | 28 | const isVirtualFile = (fileName: string) => createdBarrels.has(fileName) 29 | 30 | const upsertBarrel: UpsertBarrel = ({ barrelName, includedFiles }) => { 31 | createdBarrels.set( 32 | barrelName, 33 | includedFiles.reduce((acc, file) => { 34 | acc[file] = getModuleVersion(file) ?? 'unknown' 35 | return acc 36 | }, {}) 37 | ) 38 | } 39 | 40 | projectFolders.forEach((folderName) => { 41 | upsertBarrel({ 42 | barrelName: typescript.combinePaths(folderName, 'index.ts'), 43 | includedFiles: [], 44 | }) 45 | }) 46 | 47 | return { 48 | createdBarrels, 49 | isVirtualFile, 50 | upsertBarrel, 51 | deleteBarrel: (fileName: string) => createdBarrels.delete(fileName), 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/createBarrelUpdaterCallback.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateBarrel, 3 | getDirectoryFiles, 4 | } from '@typescript-virtual-barrel/core' 5 | import typescript from 'typescript' 6 | import tslibrary from 'typescript/lib/tsserverlibrary' 7 | import path from 'path' 8 | import { BarrelCache, UpsertBarrel } from './createBarrelRegistry' 9 | 10 | type RegisterProjectUpdateListenerParameters = { 11 | project: tslibrary.server.Project 12 | createdBarrels: BarrelCache 13 | getProgram: () => tslibrary.Program | undefined 14 | readDirectory: tslibrary.server.ServerHost['readDirectory'] 15 | directoryExists: tslibrary.server.ServerHost['directoryExists'] 16 | getModuleVersion: (fileName: string) => string 17 | deleteBarrel: (fileName: string) => void 18 | upsertBarrel: UpsertBarrel 19 | } 20 | 21 | export const createBarrelUpdaterCallback = ({ 22 | project, 23 | createdBarrels, 24 | getProgram, 25 | readDirectory, 26 | directoryExists, 27 | getModuleVersion, 28 | deleteBarrel, 29 | upsertBarrel, 30 | }: RegisterProjectUpdateListenerParameters) => { 31 | const shouldHydrateBarrel = (barrelPath: string) => { 32 | const folderName = path.dirname(barrelPath) 33 | const existingBarrel = createdBarrels.get(barrelPath) 34 | const program = getProgram() 35 | 36 | if (!existingBarrel || !program) { 37 | return 38 | } 39 | 40 | const existingBarrelFiles = Object.keys(existingBarrel) 41 | 42 | if (existingBarrelFiles.length === 0) { 43 | return true 44 | } 45 | 46 | const directoryFiles = getDirectoryFiles( 47 | folderName, 48 | program as unknown as typescript.Program, 49 | readDirectory 50 | ) 51 | 52 | if ( 53 | directoryFiles.sort().toString() !== existingBarrelFiles.sort().toString() 54 | ) { 55 | return true 56 | } 57 | 58 | for (const [fileName, version] of Object.entries(existingBarrel)) { 59 | if (getModuleVersion(fileName) !== version) { 60 | return true 61 | } 62 | } 63 | 64 | return false 65 | } 66 | 67 | const updateExistingBarrels = () => { 68 | createdBarrels.forEach((_, barrelFileName) => { 69 | const scriptInfo = project.getScriptInfo(barrelFileName) 70 | const barrelDir = path.dirname(barrelFileName) 71 | 72 | if (!scriptInfo) { 73 | deleteBarrel(barrelFileName) 74 | 75 | return 76 | } 77 | 78 | if (!directoryExists(barrelDir)) { 79 | deleteBarrel(barrelFileName) 80 | project.removeFile(scriptInfo, false, true) 81 | 82 | return 83 | } 84 | 85 | if (!shouldHydrateBarrel(barrelFileName)) { 86 | return 87 | } 88 | 89 | const newBarrel = calculateBarrel( 90 | getProgram() as unknown as typescript.Program, 91 | path.dirname(barrelFileName), 92 | readDirectory 93 | ) 94 | 95 | upsertBarrel({ 96 | barrelName: barrelFileName, 97 | includedFiles: newBarrel.includedFiles, 98 | }) 99 | 100 | scriptInfo.editContent( 101 | 0, 102 | scriptInfo.getSnapshot().getLength(), 103 | typescript.createPrinter().printFile(newBarrel.sourceFile) || 104 | 'export {};' 105 | ) 106 | }) 107 | } 108 | 109 | return updateExistingBarrels 110 | } 111 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/getOriginalCasedFileName.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export const getOriginalCasedFileName = ( 4 | _fileName: string, 5 | rootDir: string 6 | ) => { 7 | let fileName = _fileName.replace(rootDir, '') 8 | 9 | /** 10 | * If the result is the same after replacing the rootDir for nothing, 11 | * then this means the casing is different 12 | */ 13 | if (fileName === _fileName) { 14 | fileName = _fileName.replace(rootDir.toLowerCase(), '') 15 | } 16 | 17 | return path.join(rootDir, fileName) 18 | } 19 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/getProjectInfo.ts: -------------------------------------------------------------------------------- 1 | import typescript from 'typescript' 2 | import tslibrary from 'typescript/lib/tsserverlibrary' 3 | 4 | export const getProjectInfo = (info: tslibrary.server.PluginCreateInfo) => { 5 | const rootDir = info.project.getCurrentDirectory() 6 | const config = tslibrary.readConfigFile( 7 | info.project.getProjectName(), 8 | info.serverHost.readFile 9 | ) 10 | 11 | const { fileNames: rootFileNames, wildcardDirectories = {} } = 12 | tslibrary.parseJsonConfigFileContent( 13 | config.config, 14 | info.serverHost, 15 | rootDir, 16 | info.project.getCompilerOptions(), 17 | info.project.getProjectName() 18 | ) 19 | 20 | return { 21 | rootDir, 22 | rootFileNames: rootFileNames.map(typescript.normalizePath), 23 | projectDirs: Object.keys(wildcardDirectories), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import util from 'util' 2 | import typescript from 'typescript' 3 | import tslibrary from 'typescript/lib/tsserverlibrary' 4 | import { 5 | getFoldersWithoutIndexFile, 6 | patchMethod, 7 | } from '@typescript-virtual-barrel/core' 8 | import { getProjectInfo } from './getProjectInfo' 9 | import { createBarrelRegistry } from './createBarrelRegistry' 10 | import { patchLanguageServiceHostModuleResolution } from './patchLanguageServiceHostModuleResolution' 11 | import { patchServerHostFileResolution } from './patchServerHostFileResolution' 12 | import { createBarrelUpdaterCallback } from './createBarrelUpdaterCallback' 13 | import { patchGetSemanticDiagnostics } from './patchGetSemanticDiagnostics' 14 | import { patchFindReferences } from './patchFindReferences' 15 | import { patchFindRenameLocations } from './patchFindRenameLocations' 16 | 17 | function init() { 18 | function create(info: tslibrary.server.PluginCreateInfo) { 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | const logger = (data: T) => 21 | info.project.projectService.logger.info(util.inspect(data, true, 10)) 22 | 23 | const { rootFileNames, rootDir, projectDirs } = getProjectInfo(info) 24 | 25 | const getModuleVersion = (fileName: string) => 26 | info.languageServiceHost.getScriptVersion(fileName) 27 | 28 | const { createdBarrels, isVirtualFile, upsertBarrel, deleteBarrel } = 29 | createBarrelRegistry({ 30 | projectFolders: getFoldersWithoutIndexFile(rootFileNames), 31 | getModuleVersion, 32 | }) 33 | 34 | /** 35 | * The language service host takes care of module resolution, 36 | * so if we are adding barrels, we must let it know about 37 | * our intentions. 38 | */ 39 | patchLanguageServiceHostModuleResolution({ 40 | languageServiceHost: info.languageServiceHost, 41 | getAdditionalFileNames: () => [...createdBarrels.keys()], 42 | resolveModuleName: (moduleName, containingFile) => 43 | typescript.resolveModuleName( 44 | moduleName, 45 | containingFile, 46 | info.project.getCompilerOptions() as typescript.CompilerOptions, 47 | info.serverHost 48 | ), 49 | }) 50 | 51 | const createBarrelAndUpdateProject = (fileName: string) => { 52 | upsertBarrel({ 53 | barrelName: fileName, 54 | includedFiles: [], 55 | }) 56 | setTimeout(() => info.project.updateGraph(), 0) 57 | } 58 | 59 | const deleteBarrelAndUpdateProject = (fileName: string) => { 60 | const scriptInfo = info.project.getScriptInfo(fileName) 61 | 62 | if (scriptInfo) { 63 | info.project.removeFile(scriptInfo, false, true) 64 | } 65 | 66 | deleteBarrel(fileName) 67 | setTimeout(() => info.project.updateGraph(), 0) 68 | } 69 | 70 | /** 71 | * The server host resolves the files. It is responsible for 72 | * looking through the files and folders of the project 73 | * and creating the barrel. 74 | */ 75 | patchServerHostFileResolution({ 76 | serverHost: info.serverHost, 77 | rootDir, 78 | projectDirs, 79 | isVirtualFile, 80 | createBarrel: createBarrelAndUpdateProject, 81 | deleteBarrel: deleteBarrelAndUpdateProject, 82 | }) 83 | 84 | /** 85 | * Once the project is updated, we need to go through 86 | * all the created barrels and update them. We also 87 | * delete a barrel if its folder doesn't exist anymore. 88 | */ 89 | const updateExistingBarrels = createBarrelUpdaterCallback({ 90 | project: info.project, 91 | getProgram: info.languageService.getProgram, 92 | createdBarrels, 93 | upsertBarrel, 94 | deleteBarrel, 95 | getModuleVersion, 96 | directoryExists: info.serverHost.directoryExists, 97 | readDirectory: info.serverHost.readDirectory, 98 | }) 99 | 100 | patchMethod(info.project, 'updateGraph', (updateGraph) => { 101 | const updatedGraph = updateGraph() 102 | 103 | updateExistingBarrels() 104 | 105 | return updatedGraph 106 | }) 107 | 108 | /** 109 | * Since we're not using the patched program provided by 110 | * the compiler plugin, we need to inject the additional 111 | * diagnostics separately. 112 | */ 113 | patchGetSemanticDiagnostics(info) 114 | 115 | /** 116 | * The generated barrel file is included by the language service 117 | * when looking for references and rename locations. Those patches 118 | * filter barrel files so the editing experience stays the same. 119 | */ 120 | patchFindReferences(info, isVirtualFile) 121 | patchFindRenameLocations(info, isVirtualFile) 122 | 123 | return info.languageService 124 | } 125 | 126 | return { 127 | create, 128 | } 129 | } 130 | 131 | export = init 132 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/patchFindReferences.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import tslibrary from 'typescript/lib/tsserverlibrary' 3 | 4 | export const patchFindReferences = ( 5 | info: tslibrary.server.PluginCreateInfo, 6 | isVirtualFile: (fileName: string) => boolean 7 | ) => { 8 | patchMethod( 9 | info.languageService, 10 | 'findReferences', 11 | (original, fileName, position) => { 12 | const references = original(fileName, position) ?? [] 13 | const newReferences: tslibrary.ReferencedSymbol[] = [] 14 | 15 | for (const reference of references) { 16 | const { definition, references } = reference 17 | 18 | if (!isVirtualFile(definition.fileName)) { 19 | newReferences.push({ 20 | definition, 21 | references: references.filter( 22 | (ref) => !isVirtualFile(ref.fileName) 23 | ), 24 | }) 25 | continue 26 | } 27 | 28 | if (newReferences.length === 0) { 29 | continue 30 | } 31 | 32 | newReferences[newReferences.length - 1].references.push( 33 | ...references.filter((ref) => !isVirtualFile(ref.fileName)) 34 | ) 35 | } 36 | 37 | return newReferences 38 | } 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/patchFindRenameLocations.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import tslibrary from 'typescript/lib/tsserverlibrary' 3 | import path from 'path' 4 | 5 | export const patchFindRenameLocations = ( 6 | info: tslibrary.server.PluginCreateInfo, 7 | isVirtualFile: (fileName: string) => boolean 8 | ) => { 9 | patchMethod( 10 | info.languageService, 11 | 'findRenameLocations', 12 | (original, fileName, position, ...args) => { 13 | const originalRenameLocations = original(fileName, position, ...args) 14 | 15 | const folderBarrel = path.join(path.dirname(fileName), 'index.ts') 16 | 17 | if (!originalRenameLocations || !isVirtualFile(folderBarrel)) { 18 | return originalRenameLocations 19 | } 20 | 21 | return info.languageService 22 | .findReferences(fileName, position) 23 | ?.map((symbol) => symbol.references) 24 | .flat() 25 | } 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/patchGetSemanticDiagnostics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getExportsOfSourceFile, 3 | patchMethod, 4 | } from '@typescript-virtual-barrel/core' 5 | import tslibrary from 'typescript/lib/tsserverlibrary' 6 | import typescript from 'typescript' 7 | 8 | export const patchGetSemanticDiagnostics = ( 9 | info: tslibrary.server.PluginCreateInfo 10 | ) => { 11 | patchMethod( 12 | info.languageService, 13 | 'getSemanticDiagnostics', 14 | (original, fileName) => { 15 | const originalDiagnostics = original(fileName) 16 | 17 | const program = info.languageService.getProgram() 18 | 19 | if (!program) { 20 | return originalDiagnostics 21 | } 22 | 23 | const sourceFile = program.getSourceFile(fileName) 24 | 25 | if (!sourceFile) { 26 | return originalDiagnostics 27 | } 28 | 29 | const result = getExportsOfSourceFile({ 30 | sourceFile: sourceFile as typescript.SourceFile, 31 | checker: program.getTypeChecker() as unknown as typescript.TypeChecker, 32 | }) 33 | 34 | const additionalDiagnostics = result?.diagnostics ?? [] 35 | 36 | if (additionalDiagnostics.length > 0) { 37 | originalDiagnostics.push( 38 | ...(additionalDiagnostics as tslibrary.Diagnostic[]) 39 | ) 40 | } 41 | 42 | return originalDiagnostics 43 | } 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/patchLanguageServiceHostModuleResolution.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import tslibrary from 'typescript/lib/tsserverlibrary' 3 | 4 | type PatchLanguageServiceHostModuleResolution = { 5 | languageServiceHost: tslibrary.LanguageServiceHost 6 | getAdditionalFileNames: () => string[] 7 | resolveModuleName: ( 8 | moduleName: string, 9 | containingFile: string 10 | ) => tslibrary.ResolvedModuleWithFailedLookupLocations 11 | } 12 | 13 | export const patchLanguageServiceHostModuleResolution = ({ 14 | languageServiceHost, 15 | getAdditionalFileNames, 16 | resolveModuleName, 17 | }: PatchLanguageServiceHostModuleResolution) => { 18 | patchMethod( 19 | languageServiceHost, 20 | 'resolveModuleNameLiterals', 21 | (original, moduleNames, containingFile, ...args) => { 22 | const result = original?.(moduleNames, containingFile, ...args) 23 | 24 | if (!result) { 25 | return result 26 | } 27 | 28 | return moduleNames.map((moduleName, index) => { 29 | const resolution = result[index] 30 | 31 | if (resolution) { 32 | return resolution 33 | } 34 | 35 | // This is necessary so it uses the patched serverHost that includes barrel files 36 | const { resolvedModule } = resolveModuleName( 37 | moduleName.text.trim(), 38 | containingFile 39 | ) 40 | 41 | if (resolvedModule) { 42 | return { 43 | resolvedFileName: resolvedModule.resolvedFileName, 44 | isExternalLibraryImport: false, 45 | } 46 | } 47 | 48 | return resolution 49 | }) 50 | } 51 | ) 52 | 53 | patchMethod(languageServiceHost, 'getScriptFileNames', (original) => { 54 | return [...new Set([...original(), ...getAdditionalFileNames()])] 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /packages/language-service-plugin/src/patchServerHostFileResolution.ts: -------------------------------------------------------------------------------- 1 | import { patchMethod } from '@typescript-virtual-barrel/core' 2 | import path from 'path' 3 | import tslibrary from 'typescript/lib/tsserverlibrary' 4 | import { getOriginalCasedFileName } from './getOriginalCasedFileName' 5 | 6 | type PatchServerHostFileResolutionParameters = { 7 | serverHost: tslibrary.server.ServerHost 8 | rootDir: string 9 | projectDirs: string[] 10 | isVirtualFile: (fileName: string) => boolean 11 | deleteBarrel: (fileName: string) => void 12 | createBarrel: (fileName: string) => void 13 | } 14 | 15 | export const patchServerHostFileResolution = ({ 16 | serverHost, 17 | rootDir, 18 | projectDirs, 19 | isVirtualFile, 20 | deleteBarrel, 21 | createBarrel, 22 | }: PatchServerHostFileResolutionParameters) => { 23 | patchMethod(serverHost, 'directoryExists', (original, dirName) => { 24 | const directoryExists = original(dirName) 25 | 26 | if (directoryExists) { 27 | return true 28 | } 29 | 30 | const barrelCandidate = path.join(dirName, 'index.ts') 31 | 32 | /** 33 | * If the directory doesn't exist, but there is a leftover barrel 34 | * in the registry, let's remove it. 35 | */ 36 | if (isVirtualFile(barrelCandidate)) { 37 | deleteBarrel(barrelCandidate) 38 | } 39 | 40 | return false 41 | }) 42 | 43 | patchMethod(serverHost, 'fileExists', (original, fileName) => { 44 | const result = original(fileName) 45 | 46 | /** 47 | * If the file exists on the disk, let's remove the barrel and update the project 48 | */ 49 | if (result) { 50 | const barrelFile = getOriginalCasedFileName(fileName, rootDir) 51 | 52 | if (isVirtualFile(barrelFile)) { 53 | deleteBarrel(barrelFile) 54 | } 55 | 56 | return true 57 | } 58 | 59 | const barrelCandidate = getOriginalCasedFileName(fileName, rootDir) 60 | 61 | /** 62 | * If a directory was just created, let's create a barrel for it 63 | */ 64 | const isDirectory = path.extname(barrelCandidate) === '' 65 | const directoryExists = serverHost.directoryExists(barrelCandidate) 66 | const isProjectDir = projectDirs.find((dir) => 67 | barrelCandidate.toLowerCase().includes(dir.toLowerCase()) 68 | ) 69 | 70 | if (isDirectory && directoryExists && isProjectDir) { 71 | const barrelFile = path.join(barrelCandidate, 'index.ts') 72 | createBarrel(barrelFile) 73 | 74 | return true 75 | } 76 | 77 | /** 78 | * If the server is checking whether an index file exist, 79 | * and we know that it doesn't exist on the disk, 80 | * let's create a barrel 81 | */ 82 | const isIndexFile = barrelCandidate.endsWith('index.ts') 83 | const barrelDirExists = serverHost.directoryExists( 84 | path.dirname(barrelCandidate) 85 | ) 86 | 87 | if (isIndexFile && isProjectDir && barrelDirExists) { 88 | createBarrel(barrelCandidate) 89 | 90 | return true 91 | } 92 | 93 | return false 94 | }) 95 | 96 | patchMethod(serverHost, 'readFile', (original, fileName) => { 97 | const result = original(fileName) 98 | 99 | /** 100 | * Let's bail if the file exists on the disk 101 | */ 102 | if (result !== undefined) { 103 | return result 104 | } 105 | 106 | const barrelFile = getOriginalCasedFileName(fileName, rootDir) 107 | 108 | /** 109 | * If there is a barrel, let's return a valid module 110 | */ 111 | if (isVirtualFile(barrelFile)) { 112 | return 'export {};' 113 | } 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/diagnostic-information.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | import ts from 'typescript/lib/tsserverlibrary' 3 | 4 | describe('diagnostic information', () => { 5 | it('warns about default export without identifiers', () => { 6 | const files = new Map([ 7 | [ 8 | prefix('/src/components/a.ts'), 9 | `const hello = 'world'; export default function() {}`, 10 | ], 11 | ]) 12 | 13 | const { project } = createProject(files) 14 | 15 | const diagnostics = project 16 | .getLanguageService() 17 | .getSemanticDiagnostics(prefix('/src/components/a.ts')) 18 | 19 | expect(diagnostics).toContainEqual( 20 | expect.objectContaining({ 21 | category: ts.DiagnosticCategory.Warning, 22 | length: 28, 23 | messageText: 24 | 'Missing identifier for export. This member will not be included in the barrel.', 25 | reportsDeprecated: undefined, 26 | reportsUnnecessary: undefined, 27 | start: 23, 28 | }) 29 | ) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/find-references.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | 3 | describe('find references', () => { 4 | it('does not include the barrel file as a reference to the symbol', () => { 5 | const files = new Map([ 6 | [prefix('/src/index.ts'), `import { value } from './components';`], 7 | [prefix('/src/components/a.ts'), 'export const value = 1'], 8 | ]) 9 | 10 | const { project } = createProject(files) 11 | 12 | const references = 13 | project 14 | .getLanguageService() 15 | // What is the difference between findReferences and getReferencesAtPosition? 16 | .findReferences(prefix('/src/components/a.ts'), 16) ?? [] 17 | 18 | // 2 entries: definition and reference 19 | expect(references).toHaveLength(2) 20 | 21 | expect(references[0]).toEqual( 22 | expect.objectContaining({ 23 | definition: expect.objectContaining({ 24 | fileName: expect.stringContaining('/src/components/a.ts'), 25 | name: 'const value: 1', 26 | }), 27 | references: [ 28 | expect.objectContaining({ 29 | fileName: expect.stringContaining('/src/components/a.ts'), 30 | isDefinition: true, 31 | }), 32 | ], 33 | }) 34 | ) 35 | 36 | expect(references[1]).toEqual( 37 | expect.objectContaining({ 38 | definition: expect.objectContaining({ 39 | fileName: expect.stringContaining('/src/index.ts'), 40 | kind: 'alias', 41 | name: '(alias) const value: 1\nimport value', 42 | }), 43 | references: [ 44 | expect.objectContaining({ 45 | fileName: expect.stringContaining('/src/index.ts'), 46 | isDefinition: false, 47 | textSpan: { length: 5, start: 9 }, 48 | }), 49 | ], 50 | }) 51 | ) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/go-to-definition.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | 3 | describe('go to definition', () => { 4 | it('jumps over the barrel reexport and locates the original symbol definition', () => { 5 | const files = new Map([ 6 | [ 7 | prefix('/src/index.ts'), 8 | `import { value } from './components'; const result = 1 + value`, 9 | ], 10 | [prefix('/src/components/a.ts'), 'export const value = 1'], 11 | ]) 12 | 13 | const { project } = createProject(files) 14 | 15 | const definitions = project 16 | .getLanguageService() 17 | .getDefinitionAtPosition(prefix('/src/index.ts'), 61) 18 | 19 | expect(definitions).toHaveLength(1) 20 | expect(definitions).toContainEqual( 21 | expect.objectContaining({ 22 | containerName: expect.stringContaining('src/components/a'), 23 | contextSpan: { length: 22, start: 0 }, 24 | failedAliasResolution: undefined, 25 | fileName: expect.stringContaining('src/components/a'), 26 | isAmbient: false, 27 | isLocal: false, 28 | kind: 'const', 29 | name: 'value', 30 | textSpan: { length: 5, start: 13 }, 31 | unverified: false, 32 | }) 33 | ) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/import-suggestions.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | 3 | describe('import suggestions', () => { 4 | it('provides import suggestions for generated barrels', () => { 5 | const files = new Map([ 6 | [prefix('/src/index.ts'), `import {} from './components'`], 7 | [prefix('/src/components/a.ts'), 'export const value = 1'], 8 | ]) 9 | 10 | const { project } = createProject(files) 11 | 12 | const completions = project 13 | .getLanguageService() 14 | .getCompletionsAtPosition(prefix('/src/index.ts'), 8, {})?.entries 15 | 16 | expect(completions).toContainEqual( 17 | expect.objectContaining({ 18 | name: 'value', 19 | }) 20 | ) 21 | }) 22 | 23 | it('updates import suggestions on barrel-related changes within the editor', () => { 24 | const files = new Map([ 25 | [prefix('/src/index.ts'), `import {} from './components'`], 26 | [prefix('/src/components/a.ts'), 'export const value = 1'], 27 | ]) 28 | 29 | const { project } = createProject(files) 30 | 31 | const completions = project 32 | .getLanguageService() 33 | .getCompletionsAtPosition(prefix('/src/index.ts'), 8, {})?.entries 34 | 35 | expect(completions).toContainEqual( 36 | expect.objectContaining({ 37 | name: 'value', 38 | }) 39 | ) 40 | 41 | const changedFile = prefix('/src/components/a.ts') 42 | 43 | project.projectService.openClientFile(changedFile) 44 | const scriptInfo = project.getScriptInfo(changedFile) 45 | 46 | if (!scriptInfo) { 47 | throw new Error('scriptInfo for open file not found') 48 | } 49 | 50 | scriptInfo.editContent( 51 | 0, 52 | scriptInfo.getSnapshot().getLength(), 53 | 'export const other = 2' 54 | ) 55 | 56 | const newCompletions = project 57 | .getLanguageService() 58 | .getCompletionsAtPosition(prefix('/src/index.ts'), 8, {})?.entries 59 | 60 | expect(newCompletions).not.toContainEqual( 61 | expect.objectContaining({ 62 | name: 'value', 63 | }) 64 | ) 65 | 66 | expect(newCompletions).toContainEqual( 67 | expect.objectContaining({ 68 | name: 'other', 69 | }) 70 | ) 71 | }) 72 | 73 | it('updates import suggestions on barrel-related external changes', () => { 74 | const files = new Map([ 75 | [prefix('/src/index.ts'), `import {} from './components'`], 76 | [prefix('/src/components/a.ts'), 'export const value = 1'], 77 | ]) 78 | 79 | const { project, fileSystem } = createProject(files) 80 | 81 | const completions = project 82 | .getLanguageService() 83 | .getCompletionsAtPosition(prefix('/src/index.ts'), 8, {})?.entries 84 | 85 | expect(completions).toContainEqual( 86 | expect.objectContaining({ 87 | name: 'value', 88 | }) 89 | ) 90 | 91 | const changedFile = prefix('/src/components/a.ts') 92 | files.set(changedFile, 'export const other = 2') 93 | fileSystem.notifyChanges(changedFile) 94 | 95 | const newCompletions = project 96 | .getLanguageService() 97 | .getCompletionsAtPosition(prefix('/src/index.ts'), 8, {})?.entries 98 | 99 | expect(newCompletions).not.toContainEqual( 100 | expect.objectContaining({ 101 | name: 'value', 102 | }) 103 | ) 104 | 105 | expect(newCompletions).toContainEqual( 106 | expect.objectContaining({ 107 | name: 'other', 108 | }) 109 | ) 110 | }) 111 | 112 | it('adds barrel import as a suggestion', () => { 113 | const files = new Map([ 114 | [prefix('/src/index.ts'), `const result = 1 + value`], 115 | [prefix('/src/components/a.ts'), 'export const value = 1'], 116 | ]) 117 | 118 | const { project } = createProject(files) 119 | 120 | const completions = project 121 | .getLanguageService() 122 | .getCompletionsAtPosition(prefix('/src/index.ts'), 24, { 123 | includeCompletionsForModuleExports: true, 124 | allowIncompleteCompletions: true, 125 | })?.entries 126 | 127 | expect(completions).toContainEqual( 128 | expect.objectContaining({ 129 | name: 'value', 130 | source: './components', 131 | }) 132 | ) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/project.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript/lib/tsserverlibrary' 2 | import path from 'path' 3 | 4 | const noop = () => undefined 5 | 6 | export const prefix = (file: string) => path.join(__dirname, file) 7 | 8 | const logger: ts.server.Logger = { 9 | close: noop, 10 | hasLevel: () => false, 11 | loggingEnabled: () => true, 12 | perftrc: noop, 13 | info: noop, 14 | startGroup: noop, 15 | endGroup: noop, 16 | msg: noop, 17 | getLogFileName: noop, 18 | } 19 | 20 | const NOOP_FILE_WATCHER: ts.FileWatcher = { 21 | close: noop, 22 | } 23 | 24 | class MockFileWatcher implements ts.FileWatcher { 25 | constructor( 26 | private readonly fileName: string, 27 | private readonly cb: ts.FileWatcherCallback, 28 | readonly close: () => void 29 | ) {} 30 | 31 | changed() { 32 | this.cb(this.fileName, ts.FileWatcherEventKind.Changed) 33 | } 34 | 35 | deleted() { 36 | this.cb(this.fileName, ts.FileWatcherEventKind.Deleted) 37 | } 38 | } 39 | 40 | export class MockFileSystem 41 | implements 42 | Pick 43 | { 44 | private files = new Map() 45 | private watchers = new Map() 46 | 47 | constructor(files: Map) { 48 | this.files = files 49 | } 50 | 51 | readFile = (file: string, encoding?: string) => { 52 | const read = this.files.get(file) ?? ts.sys.readFile(file, encoding) 53 | return read 54 | } 55 | 56 | fileExists = (file: string) => { 57 | return this.files.has(file) || ts.sys.fileExists(file) 58 | } 59 | 60 | watchFile = (path: string, callback: ts.FileWatcherCallback) => { 61 | const watcher = new MockFileWatcher(path, callback, () => { 62 | this.watchers.delete(path) 63 | }) 64 | this.watchers.set(path, watcher) 65 | return watcher 66 | } 67 | 68 | directoryExists = (directory: string) => { 69 | const existingFiles = [...this.files.keys()] 70 | 71 | return existingFiles.some((file) => path.dirname(file).includes(directory)) 72 | } 73 | 74 | currentDirectory = () => __dirname 75 | 76 | getDirectories = () => { 77 | return [ 78 | ...new Set([...this.files.keys()].map((file) => path.dirname(file))), 79 | ] 80 | } 81 | 82 | readDirectory = (directory: string) => { 83 | const existingFiles = [...this.files.keys()] 84 | 85 | const folderFiles = existingFiles.filter((file) => 86 | path.dirname(file).includes(directory) 87 | ) 88 | 89 | return folderFiles 90 | } 91 | 92 | notifyChanges = (changedFileName: string) => { 93 | for (const [fileName, watcher] of this.watchers) { 94 | if (fileName === changedFileName) { 95 | watcher.changed() 96 | } 97 | } 98 | } 99 | } 100 | 101 | const tsConfig = { 102 | include: ['src'], 103 | compilerOptions: { 104 | plugins: [ 105 | { 106 | name: '@typescript-virtual-barrel/language-service-plugin', 107 | }, 108 | ], 109 | resolveJsonModule: true, 110 | }, 111 | } 112 | 113 | export const createProject = (files: Map) => { 114 | const fileSystem = new MockFileSystem(files) 115 | 116 | const TSCONFIG = prefix('/tsconfig.json') 117 | 118 | files.set(TSCONFIG, JSON.stringify(tsConfig)) 119 | 120 | const host: ts.server.ServerHost = { 121 | ...ts.sys, 122 | watchDirectory: () => NOOP_FILE_WATCHER, 123 | setTimeout: noop, 124 | clearTimeout: noop, 125 | setImmediate: noop, 126 | clearImmediate: noop, 127 | ...fileSystem, 128 | } 129 | 130 | const projectService = new ts.server.ProjectService({ 131 | host, 132 | cancellationToken: ts.server.nullCancellationToken, 133 | useSingleInferredProject: false, 134 | useInferredProjectPerProjectRoot: true, 135 | typingsInstaller: ts.server.nullTypingsInstaller, 136 | session: undefined, 137 | logger, 138 | serverMode: ts.LanguageServiceMode.Semantic, 139 | }) 140 | 141 | projectService.openClientFile(prefix('/src/index.ts')) 142 | 143 | const project = projectService.findProject(TSCONFIG) 144 | 145 | if (!project) { 146 | throw new Error(`Failed to create project for ${TSCONFIG}`) 147 | } 148 | 149 | return { project, fileSystem } 150 | } 151 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/rename-symbols.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | import ts from 'typescript/lib/tsserverlibrary' 3 | 4 | describe('rename symbols', () => { 5 | const assertLocationValue = ( 6 | content: string, 7 | location: ts.RenameLocation, 8 | value: string 9 | ) => { 10 | expect( 11 | content.substring( 12 | location.textSpan.start, 13 | location.textSpan.start + location.textSpan.length 14 | ) 15 | ).toEqual(value) 16 | } 17 | 18 | it('renames member exports in the origin and usages', () => { 19 | const indexTs = `import { value } from './components'; const result = 1 + value` 20 | const componentsATs = 'export const value = 1' 21 | 22 | const files = new Map([ 23 | [prefix('/src/index.ts'), indexTs], 24 | [prefix('/src/components/a.ts'), componentsATs], 25 | ]) 26 | 27 | const { project } = createProject(files) 28 | 29 | const renameInfo = 30 | project 31 | .getLanguageService() 32 | .findRenameLocations( 33 | prefix('/src/components/a.ts'), 34 | 16, 35 | false, 36 | false 37 | ) ?? [] 38 | 39 | expect(renameInfo).toHaveLength(3) 40 | expect(renameInfo).not.toContainEqual( 41 | expect.objectContaining({ 42 | fileName: expect.stringContaining('components/index.ts'), 43 | }) 44 | ) 45 | 46 | assertLocationValue(componentsATs, renameInfo[0], 'value') // rename symbol 47 | assertLocationValue(indexTs, renameInfo[1], 'value') // rename import 48 | assertLocationValue(indexTs, renameInfo[2], 'value') // reference usage 49 | }) 50 | 51 | it('renames named exports in the origin and usages', () => { 52 | const indexTs = `import { value } from './components'; const result = 1 + value` 53 | const componentsATs = 'const value = 1; export { value }' 54 | 55 | const files = new Map([ 56 | [prefix('/src/index.ts'), indexTs], 57 | [prefix('/src/components/a.ts'), componentsATs], 58 | ]) 59 | 60 | const { project } = createProject(files) 61 | 62 | const renameInfo = 63 | project 64 | .getLanguageService() 65 | .findRenameLocations(prefix('/src/components/a.ts'), 9, false, false) ?? 66 | [] 67 | 68 | expect(renameInfo).toHaveLength(4) 69 | expect(renameInfo).not.toContainEqual( 70 | expect.objectContaining({ 71 | fileName: expect.stringContaining('components/index.ts'), 72 | }) 73 | ) 74 | 75 | assertLocationValue(componentsATs, renameInfo[0], 'value') // rename symbol 76 | assertLocationValue(componentsATs, renameInfo[1], 'value') // rename export 77 | assertLocationValue(indexTs, renameInfo[2], 'value') // rename import 78 | assertLocationValue(indexTs, renameInfo[3], 'value') // rename usage 79 | }) 80 | 81 | it('renames reference to default export in the origin and usages', () => { 82 | const indexTs = `import { value } from './components'; const result = 1 + value` 83 | const componentsATs = 'const value = 1; export default value' 84 | 85 | const files = new Map([ 86 | [prefix('/src/index.ts'), indexTs], 87 | [prefix('/src/components/a.ts'), componentsATs], 88 | ]) 89 | 90 | const { project } = createProject(files) 91 | 92 | const renameInfo = 93 | project 94 | .getLanguageService() 95 | .findRenameLocations(prefix('/src/components/a.ts'), 9, false, false) ?? 96 | [] 97 | 98 | expect(renameInfo).toHaveLength(4) 99 | expect(renameInfo).not.toContainEqual( 100 | expect.objectContaining({ 101 | fileName: expect.stringContaining('components/index.ts'), 102 | }) 103 | ) 104 | 105 | assertLocationValue(componentsATs, renameInfo[0], 'value') // rename symbol 106 | assertLocationValue(componentsATs, renameInfo[1], 'value') // rename export 107 | assertLocationValue(indexTs, renameInfo[2], 'value') // rename import 108 | assertLocationValue(indexTs, renameInfo[3], 'value') // rename usage 109 | }) 110 | 111 | it('renames inline default exports in the origin and usages', () => { 112 | const indexTs = `import { doSomething } from './components'; const result = doSomething()` 113 | const componentsATs = 'export default function doSomething() {}' 114 | 115 | const files = new Map([ 116 | [prefix('/src/index.ts'), indexTs], 117 | [prefix('/src/components/a.ts'), componentsATs], 118 | ]) 119 | 120 | const { project } = createProject(files) 121 | 122 | const renameInfo = 123 | project 124 | .getLanguageService() 125 | .findRenameLocations( 126 | prefix('/src/components/a.ts'), 127 | 30, 128 | false, 129 | false 130 | ) ?? [] 131 | 132 | expect(renameInfo).toHaveLength(3) 133 | expect(renameInfo).not.toContainEqual( 134 | expect.objectContaining({ 135 | fileName: expect.stringContaining('components/index.ts'), 136 | }) 137 | ) 138 | 139 | assertLocationValue(componentsATs, renameInfo[0], 'doSomething') 140 | assertLocationValue(indexTs, renameInfo[1], 'doSomething') 141 | assertLocationValue(indexTs, renameInfo[2], 'doSomething') 142 | }) 143 | 144 | it('updates namespace imports whenever an exported symbol changes', () => { 145 | const indexTs = `import * as components from './components'; const result = components.doSomething()` 146 | const componentsATs = 'export function doSomething() {}' 147 | 148 | const files = new Map([ 149 | [prefix('/src/index.ts'), indexTs], 150 | [prefix('/src/components/a.ts'), componentsATs], 151 | ]) 152 | 153 | const { project } = createProject(files) 154 | 155 | const renameInfo = 156 | project 157 | .getLanguageService() 158 | .findRenameLocations( 159 | prefix('/src/components/a.ts'), 160 | 23, 161 | false, 162 | false 163 | ) ?? [] 164 | 165 | expect(renameInfo).toHaveLength(2) 166 | expect(renameInfo).not.toContainEqual( 167 | expect.objectContaining({ 168 | fileName: expect.stringContaining('components/index.ts'), 169 | }) 170 | ) 171 | 172 | assertLocationValue(componentsATs, renameInfo[0], 'doSomething') 173 | assertLocationValue(indexTs, renameInfo[1], 'doSomething') 174 | }) 175 | 176 | it('updates namespace imports whenever a default exported symbol changes', () => { 177 | const indexTs = `import * as components from './components'; const result = components.doSomething()` 178 | const componentsATs = 'export default function doSomething() {}' 179 | 180 | const files = new Map([ 181 | [prefix('/src/index.ts'), indexTs], 182 | [prefix('/src/components/a.ts'), componentsATs], 183 | ]) 184 | 185 | const { project } = createProject(files) 186 | 187 | const renameInfo = 188 | project 189 | .getLanguageService() 190 | .findRenameLocations( 191 | prefix('/src/components/a.ts'), 192 | 33, 193 | false, 194 | false 195 | ) ?? [] 196 | 197 | expect(renameInfo).toHaveLength(2) 198 | expect(renameInfo).not.toContainEqual( 199 | expect.objectContaining({ 200 | fileName: expect.stringContaining('components/index.ts'), 201 | }) 202 | ) 203 | 204 | assertLocationValue(componentsATs, renameInfo[0], 'doSomething') 205 | assertLocationValue(indexTs, renameInfo[1], 'doSomething') 206 | }) 207 | }) 208 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tests/type-safety.test.ts: -------------------------------------------------------------------------------- 1 | import { createProject, prefix } from './project' 2 | 3 | describe.only('type safety', () => { 4 | it('symbols imported from barrels are type rich', () => { 5 | const files = new Map([ 6 | [prefix('/src/components/x.ts'), 'export const x = 1'], 7 | [prefix('/src/components/y.ts'), 'export const y = true'], 8 | [ 9 | prefix('/src/index.ts'), 10 | `import { x, y } from './components';\nconst result = x + y`, 11 | ], 12 | ]) 13 | 14 | const { project } = createProject(files) 15 | 16 | const completions = project 17 | .getLanguageService() 18 | .getSemanticDiagnostics(prefix('/src/index.ts')) 19 | 20 | expect(completions).toContainEqual( 21 | expect.objectContaining({ 22 | messageText: 23 | "Operator '+' cannot be applied to types 'number' and 'boolean'.", 24 | start: 52, 25 | length: 5, 26 | }) 27 | ) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /packages/language-service-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["./src"], 4 | "exclude": ["dist", "node_modules"], 5 | "compilerOptions": { 6 | "module": "CommonJS", 7 | "outDir": "./dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "sourceMap": false, 18 | "strict": true, 19 | "target": "ES2015" 20 | }, 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "lint": { 9 | "outputs": [] 10 | }, 11 | "dev": { 12 | "cache": false 13 | }, 14 | "test": { 15 | "dependsOn": ["build"], 16 | "cache": false 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaguiini/typescript-virtual-barrel/ed9628a262ca15f3a6e22842e2c5342c80106370/usage.gif --------------------------------------------------------------------------------