├── .gitignore ├── LICENSE ├── README.md ├── example ├── .gitignore ├── package-lock.json ├── package.json ├── src │ ├── index.ts │ └── logics │ │ ├── AnotherLogics.ts │ │ └── SomeLogics.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── prettier.config.cjs ├── src ├── index.ts └── lib │ └── import.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Yuku Kotani 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typescript-plugin-namespace-import 2 | 3 | [![npm](https://img.shields.io/npm/v/typescript-plugin-namespace-import)](https://www.npmjs.com/package/typescript-plugin-namespace-import) 4 | [![license](https://img.shields.io/npm/l/typescript-plugin-namespace-import)](https://github.com/yukukotani/typescript-plugin-namespace-import/blob/master/LICENSE) 5 | 6 | A [TypeScript Language Service Plugin](https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin) to auto-complete and insert [Namespace Import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#import_an_entire_modules_contents). 7 | 8 | ![namespace](https://user-images.githubusercontent.com/16265411/132126846-9b2ab85b-45ad-427e-aac4-c6c408e53aa5.gif) 9 | 10 | ## Motivation 11 | 12 | [日本語の記事 / Japanese Article](https://zenn.dev/yuku/articles/4d2f665cf42385) 13 | 14 | We often use an object as a namespace. 15 | 16 | ```typescript 17 | // someLogics.ts 18 | export const someLogics = { 19 | calculate() { ... }, 20 | print() { ... }, 21 | }; 22 | 23 | // main.ts 24 | import { someLogics } from "./someLogics.ts"; 25 | 26 | someLogics.calculate() 27 | // `someLogics` is auto-completable without import! 28 | ``` 29 | 30 | This is good way in developer experience, but it obstruct tree-shaking. In this case, `someLogics.print` will be included in bundle although it's not used. 31 | 32 | To keep tree-shaking working, we can use [Namespace Import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#import_an_entire_modules_contents). 33 | 34 | ```typescript 35 | // someLogics.ts 36 | export function calculate() { ... } 37 | export function print() { ... } 38 | 39 | // main.ts 40 | import * as someLogics from "./someLogics.ts"; 41 | 42 | someLogics.calculate() 43 | // `someLogics` is NOT auto-completable without import :( 44 | ``` 45 | 46 | Now we can tree-shake `someLogics.print`. However, developer experience get worse because we can't auto-complete `someLogics` without import statement. We need to write import statement by ourselves. 47 | 48 | typescript-plugin-namespace-import resolves this problem by making Namespace Import auto-completable. 49 | 50 | ## Installation 51 | 52 | Install with npm/yarn. 53 | 54 | ```sh 55 | npm install -D typescript-plugin-namespace-import 56 | # or yarn add -D typescript-plugin-namespace-import 57 | ``` 58 | 59 | Then add this plugin in `tsconfig.json`. 60 | 61 | ```json 62 | { 63 | "compilerOptions": { 64 | "plugins": [ 65 | { 66 | "name": "typescript-plugin-namespace-import", 67 | "options": { 68 | "paths": ["src/logics"] 69 | } 70 | } 71 | ] 72 | } 73 | } 74 | ``` 75 | 76 | `paths` option is required. See below for detail. 77 | 78 | ## Options 79 | 80 | ### paths (required) 81 | 82 | Value: `string[]` 83 | 84 | Specify directory in relative path to the project's root (`tsconfig.json`'s dir). All `.ts` or `.js` files in the directories can be Namespace Imported with auto-completion. 85 | 86 | Example: 87 | 88 | ```json 89 | "options": { 90 | "paths": ["src/logics"] 91 | } 92 | ``` 93 | 94 | ### ignoreNamedExport 95 | 96 | Value: `boolean` 97 | 98 | If true, named export from files in `paths` won't be shown in auto-completion. 99 | 100 | Example: 101 | 102 | ```json 103 | "options": { 104 | "paths": ["src/logics"], 105 | "ignoreNamedExport": true 106 | } 107 | ``` 108 | 109 | ### nameTransform 110 | 111 | Value: `"upperCamelCase" | "lowerCamelCase"` 112 | 113 | Transform import name. If not set, the filename will be used as an import name. 114 | 115 | Example: 116 | 117 | ```json 118 | "options": { 119 | "paths": ["src/logics"], 120 | "nameTransform": "lowerCamelCase" 121 | } 122 | ``` 123 | 124 | Then `SomeLogic.ts` will be imported like `import * as someLogic from "./SomeLogic"`. 125 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /example/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "camelcase": { 8 | "version": "6.2.0", 9 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", 10 | "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", 11 | "dev": true 12 | }, 13 | "typescript": { 14 | "version": "4.4.2", 15 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.2.tgz", 16 | "integrity": "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ==", 17 | "dev": true 18 | }, 19 | "typescript-plugin-namespace-import": { 20 | "version": "0.3.1", 21 | "resolved": "https://registry.npmjs.org/typescript-plugin-namespace-import/-/typescript-plugin-namespace-import-0.3.1.tgz", 22 | "integrity": "sha512-E4Dd5X6dvHkBErW1qOi5NvxMm7WIArdgmB1Wa3V8y6jxqsu4LWLfV5CDNgsXwnQMf1z2ERQmknyJraMqgeEYeA==", 23 | "dev": true, 24 | "requires": { 25 | "camelcase": "^6.2.0", 26 | "typescript": "^4.3.5" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "author": "Yuku Kotani (yukukotani@gmail.com)", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "typescript": "^4.4.2", 11 | "typescript-plugin-namespace-import": "^0.3.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as SomeLogics from './logics/SomeLogics'; 2 | 3 | SomeLogics.divide(4, 2); 4 | 5 | // You can auto-complete `AnotherLogics` here! 6 | -------------------------------------------------------------------------------- /example/src/logics/AnotherLogics.ts: -------------------------------------------------------------------------------- 1 | export function print(msg: any) { 2 | console.log(msg); 3 | } 4 | 5 | export function printError(error: any) { 6 | console.error(error); 7 | } 8 | -------------------------------------------------------------------------------- /example/src/logics/SomeLogics.ts: -------------------------------------------------------------------------------- 1 | export function multiply(a: number, b: number): number { 2 | return a * b; 3 | } 4 | 5 | export function divide(a: number, b: number): number { 6 | return a / b; 7 | } 8 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "noEmit": true, 7 | "plugins": [ 8 | { 9 | "name": "typescript-plugin-namespace-import", 10 | "options": { 11 | "paths": ["src/logics"], 12 | "ignoreNamedExport": true 13 | } 14 | } 15 | ] 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-plugin-namespace-import", 3 | "version": "0.3.3", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/node": { 8 | "version": "16.7.10", 9 | "resolved": "https://registry.npmjs.org/@types/node/-/node-16.7.10.tgz", 10 | "integrity": "sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==", 11 | "dev": true 12 | }, 13 | "@ubie/prettier-config": { 14 | "version": "0.1.0", 15 | "resolved": "https://registry.npmjs.org/@ubie/prettier-config/-/prettier-config-0.1.0.tgz", 16 | "integrity": "sha512-4K/gXy6hrYBYqioEaYHdRNrFVEtpXpr4VS3E5HzZO8RNCNSSnuJMYg6RIkIpjoSHJWa9INC7h9GEdP9MzkZQzw==", 17 | "dev": true 18 | }, 19 | "camelcase": { 20 | "version": "6.2.0", 21 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", 22 | "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==" 23 | }, 24 | "prettier": { 25 | "version": "2.3.2", 26 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", 27 | "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", 28 | "dev": true 29 | }, 30 | "tslib": { 31 | "version": "1.14.1", 32 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", 33 | "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" 34 | }, 35 | "tsutils": { 36 | "version": "3.21.0", 37 | "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", 38 | "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", 39 | "requires": { 40 | "tslib": "^1.8.1" 41 | } 42 | }, 43 | "typescript": { 44 | "version": "4.3.5", 45 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", 46 | "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-plugin-namespace-import", 3 | "version": "0.3.3", 4 | "description": "A TypeScript Language Service Plugin to auto-complete and insert Namespace Import.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/yukukotani/typescript-plugin-namespace-import.git" 12 | }, 13 | "author": "Yuku Kotani (yukukotani@gmail.com)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/yukukotani/typescript-plugin-namespace-import/issues" 17 | }, 18 | "homepage": "https://github.com/yukukotani/typescript-plugin-namespace-import#readme", 19 | "dependencies": { 20 | "camelcase": "^6.2.0", 21 | "tsutils": "^3.21.0", 22 | "typescript": "^4.3.5" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^16.7.10", 26 | "@ubie/prettier-config": "^0.1.0", 27 | "prettier": "^2.3.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@ubie/prettier-config"); 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ts, { SemanticClassificationFormat } from 'typescript/lib/tsserverlibrary'; 2 | import * as tsutils from 'tsutils'; 3 | import * as namespaceImportPlugin from './lib/import'; 4 | 5 | declare global { 6 | namespace ts { 7 | interface CompletionEntryData { 8 | modulePath?: string; 9 | } 10 | } 11 | } 12 | 13 | function init() { 14 | function create(info: ts.server.PluginCreateInfo) { 15 | const log = (...params: unknown[]) => { 16 | const text = params.map((p) => (p ? JSON.stringify(p) : p)).join(' '); 17 | info.project.projectService.logger.info(`[namespace-import] ${text}`); 18 | }; 19 | 20 | log('Start init'); 21 | 22 | const getCompletionsAtPosition = info.languageService.getCompletionsAtPosition; 23 | info.languageService.getCompletionsAtPosition = (fileName, position, options) => { 24 | log('getCompletionsAtPosition', { fileName, position, options }); 25 | const original = getCompletionsAtPosition(fileName, position, options); 26 | if ( 27 | original == null || 28 | options?.triggerCharacter != null || 29 | !namespaceImportPlugin.isAutoCompletablePosition( 30 | info.languageService.getProgram()?.getSourceFile(fileName), 31 | position, 32 | ) 33 | ) { 34 | return original; 35 | } 36 | 37 | const originalEntries = namespaceImportPlugin.filterNamedImportEntries(original.entries, info); 38 | const namespaceImportEntries = namespaceImportPlugin.getCompletionEntries(info); 39 | original.entries = [...originalEntries, ...namespaceImportEntries]; 40 | return original; 41 | }; 42 | 43 | const getCompletionEntryDetails = info.languageService.getCompletionEntryDetails; 44 | info.languageService.getCompletionEntryDetails = (fileName, position, name, options, source, preferences, data) => { 45 | log('getCompletionEntryDetails', { fileName, position, name, options, source }); 46 | if (data?.modulePath == null) { 47 | return getCompletionEntryDetails(fileName, position, name, options, source, preferences, data); 48 | } 49 | 50 | return namespaceImportPlugin.getCompletionEntryDetails(name, fileName, data.modulePath, info); 51 | }; 52 | 53 | const getCodeFixesAtPosition = info.languageService.getCodeFixesAtPosition; 54 | info.languageService.getCodeFixesAtPosition = (fileName, start, end, errorCodes, formatOptions, preferences) => { 55 | log('getCodeFixesAtPosition', { fileName, start, end, errorCodes, formatOptions, preferences }); 56 | const original = getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences); 57 | 58 | const importAction = namespaceImportPlugin.getCodeFixActionByName(fileName, start, end, info); 59 | if (importAction) { 60 | return [importAction, ...original]; 61 | } 62 | 63 | return original; 64 | }; 65 | } 66 | 67 | return { create }; 68 | } 69 | 70 | export = init; 71 | -------------------------------------------------------------------------------- /src/lib/import.ts: -------------------------------------------------------------------------------- 1 | import ts, { CodeFixAction, ScriptElementKind } from 'typescript/lib/tsserverlibrary'; 2 | import * as path from 'path'; 3 | import camelCase from 'camelcase'; 4 | import * as tsutils from 'tsutils'; 5 | 6 | export type PluginOptions = { 7 | paths: readonly string[]; 8 | ignoreNamedExport?: boolean; 9 | nameTransform?: 'upperCamelCase' | 'lowerCamelCase'; 10 | }; 11 | 12 | export function getCompletionEntries(info: ts.server.PluginCreateInfo): ts.CompletionEntry[] { 13 | const modulePaths = getModulePathsToImport(info.config.options, info.project); 14 | 15 | return modulePaths.map((modulePath) => { 16 | const name = transformImportName(getFileNameWithoutExt(modulePath), info.config.options); 17 | return { 18 | name: name, 19 | kind: ts.ScriptElementKind.alias, 20 | source: modulePath, 21 | sortText: name, 22 | hasAction: true, 23 | isImportStatementCompletion: true, 24 | data: { 25 | exportName: name, 26 | modulePath: modulePath, 27 | }, 28 | }; 29 | }); 30 | } 31 | 32 | export function filterNamedImportEntries( 33 | entries: ts.CompletionEntry[], 34 | info: ts.server.PluginCreateInfo, 35 | ): ts.CompletionEntry[] { 36 | const options: PluginOptions = info.config.options; 37 | if (!options.ignoreNamedExport) { 38 | return entries; 39 | } 40 | 41 | const currentDir = info.project.getCurrentDirectory(); 42 | const dirPaths = options.paths.map((dirPath) => path.resolve(currentDir, dirPath)); 43 | return entries.filter((entry) => { 44 | return !dirPaths.some((dirPath) => entry.data?.exportName && entry.data.fileName?.startsWith(dirPath)); 45 | }); 46 | } 47 | 48 | export function isAutoCompletablePosition(sourceFile: ts.SourceFile | undefined, position: number): boolean { 49 | if (!sourceFile) { 50 | return false; 51 | } 52 | const token = tsutils.getTokenAtPosition(sourceFile!, position)?.kind ?? ts.SyntaxKind.Unknown; 53 | return token !== ts.SyntaxKind.StringLiteral; 54 | } 55 | 56 | export function getCompletionEntryDetails( 57 | name: string, 58 | selfPath: string, 59 | modulePath: string, 60 | info: ts.server.PluginCreateInfo, 61 | ): ts.CompletionEntryDetails { 62 | const action: CodeFixAction = getCodeFixActionFromPath(name, selfPath, modulePath, info.project); 63 | return { 64 | name: name, 65 | kind: ScriptElementKind.alias, 66 | kindModifiers: '', 67 | displayParts: [], 68 | codeActions: [action], 69 | }; 70 | } 71 | 72 | export function getCodeFixActionByName( 73 | selfPath: string, 74 | start: number, 75 | end: number, 76 | info: ts.server.PluginCreateInfo, 77 | ): CodeFixAction | null { 78 | const name = info.languageService.getProgram()?.getSourceFile(selfPath)?.text.slice(start, end); 79 | if (!name) { 80 | return null; 81 | } 82 | 83 | const modulePaths = getModulePathsToImport(info.config.options, info.project); 84 | const modulePath = modulePaths.find((filePath) => getFileNameWithoutExt(filePath) === name); 85 | if (modulePath) { 86 | return getCodeFixActionFromPath(name, selfPath, modulePath, info.project); 87 | } else { 88 | return null; 89 | } 90 | } 91 | 92 | function getModulePathsToImport(options: PluginOptions, project: ts.server.Project): string[] { 93 | const currentDir = project.getCurrentDirectory(); 94 | 95 | const modulePaths = options.paths.flatMap((dirPath) => { 96 | return project.readDirectory(path.resolve(currentDir, dirPath), ['.ts', '.js']); 97 | }); 98 | 99 | return [...new Set(modulePaths)]; 100 | } 101 | 102 | function getFileNameWithoutExt(filePath: string): string { 103 | const ext = path.extname(filePath); 104 | return path.basename(filePath, ext); 105 | } 106 | 107 | function getFilePathWithoutExt(filePath: string): string { 108 | const ext = path.extname(filePath); 109 | return filePath.slice(0, filePath.length - ext.length); 110 | } 111 | 112 | function getModuleSpceifier(selfPath: string, modulePath: string, project: ts.server.Project) { 113 | const compilerOptions = project.getCompilerOptions(); 114 | 115 | let specifier: string; 116 | if (compilerOptions.baseUrl) { 117 | specifier = path.posix.relative(compilerOptions.baseUrl, modulePath); 118 | } else { 119 | specifier = './' + path.posix.relative(path.dirname(selfPath), modulePath); 120 | } 121 | 122 | return getFilePathWithoutExt(specifier); 123 | } 124 | 125 | function getCodeFixActionFromPath( 126 | name: string, 127 | selfPath: string, 128 | modulePath: string, 129 | project: ts.server.Project, 130 | ): CodeFixAction { 131 | const moduleSpecifier = getModuleSpceifier(selfPath, modulePath, project); 132 | const text = `import * as ${name} from "${moduleSpecifier}";\n`; 133 | return { 134 | fixName: 'namespace-import', 135 | description: text, 136 | changes: [ 137 | { 138 | fileName: selfPath, 139 | textChanges: [ 140 | { 141 | span: { 142 | start: 0, 143 | length: 0, 144 | }, 145 | newText: text, 146 | }, 147 | ], 148 | }, 149 | ], 150 | commands: [], 151 | }; 152 | } 153 | 154 | function transformImportName(name: string, options: PluginOptions) { 155 | if (options.nameTransform) { 156 | return camelCase(name, { pascalCase: options.nameTransform === 'upperCamelCase' }); 157 | } else { 158 | return name; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["esnext", "dom"], 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["./src"] 11 | } 12 | --------------------------------------------------------------------------------