├── .gitignore ├── .prettierrc ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── package.json ├── src └── index.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "compile", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: compile", 10 | "detail": "rm -rf ./dist && tsc", 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Unsplash 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 | # ts-namespace-import-plugin 2 | 3 | This plugin can help with importing common namespaces into your modules. 4 | 5 | As a code action 6 | 7 | image 8 | 9 | As completion 10 | 11 | image 12 | 13 | What is a namespace import you may ask? 14 | You can learn more about them [here](https://unsplash.com/blog/organizing-typescript-modules/) but in short it looks like the following: 15 | 16 | ```ts 17 | import * as SomeNamespace from "path/to/module"; 18 | 19 | SomeNamespace.doStuff(); 20 | ``` 21 | 22 | We like them because it gives context when a function is used as opposed to have a bunch of named imports. It also reduces naming conflicts. 23 | 24 | ## Installation 25 | 26 | ```sh 27 | yarn add --dev @unsplash/ts-namespace-import-plugin 28 | ``` 29 | 30 | Then add the following to your `tsconfig.json`. 31 | 32 | ```json 33 | { 34 | "compilerOptions": { 35 | // ...other options 36 | "plugins": [ 37 | { 38 | "name": "@unsplash/ts-namespace-import-plugin" 39 | } 40 | ] 41 | } 42 | } 43 | ``` 44 | 45 | ## Configuration 46 | 47 | ```json 48 | { 49 | "compilerOptions": { 50 | // ...other options 51 | "plugins": [ 52 | { 53 | "name": "@unsplash/ts-namespace-import-plugin", 54 | "namespaces": { 55 | "MyNamespace": { 56 | "importPath": "path/to/module" 57 | } 58 | } 59 | } 60 | ] 61 | } 62 | } 63 | ``` 64 | 65 | One configured, TS should prompt you with a code action whenever you write `MyNamespace` in any module, for instance: 66 | 67 | ```ts 68 | // import * as MyNamespace from 'path/to/module' <--- This would be added when the code action runs on `MyNamescape` 69 | MyNamespace.doFoo(); 70 | ``` 71 | 72 | ## Contribute 73 | 74 | ```sh 75 | yarn link # From this repo 76 | yarn link @unsplash/ts-namespace-import-plugin # from another repo 77 | yarn run compile # when you make changes here to reflect in your target repo 78 | ``` 79 | 80 | If you need to log things inside this plugin, they will show up in the `tsserver.log` which can be opened from your target repo using `CMD+SHIFT+P` > `Open TS Server Log`. Keep in mind that everytime you reload your VSCode to take latest changes into account, you'll have to run this again because the file may change location on your filesystem. You can also [read this](https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin#debugging) for more info. 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsplash/ts-namespace-import-plugin", 3 | "version": "1.0.0", 4 | "description": "Provides tool for refactoring unsplash web codebase", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "files": [ 8 | "./dist" 9 | ], 10 | "scripts": { 11 | "compile": "rm -rf ./dist && tsc", 12 | "prepublishOnly": "yarn run compile" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/unsplash/ts-namespace-import-plugin.git" 17 | }, 18 | "keywords": [ 19 | "typescript" 20 | ], 21 | "devDependencies": { 22 | "typescript": "^4.3.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as ts_module from "typescript/lib/tsserverlibrary"; 2 | 3 | type NamespacesConfig = { 4 | [namespace: string]: { 5 | importPath: string; 6 | }; 7 | }; 8 | 9 | const enum RefactorAction { 10 | ImportNamespace = "import-namespace", 11 | } 12 | 13 | const createCodeAction = ({ 14 | namespaceName, 15 | importPath, 16 | fileName, 17 | }: { 18 | namespaceName: string; 19 | importPath: string; 20 | fileName: string; 21 | }): ts_module.CodeFixAction => { 22 | const newText = `import * as ${namespaceName} from '${importPath}';\n`; 23 | 24 | return { 25 | fixName: RefactorAction.ImportNamespace, 26 | description: `Add namespace import of "${importPath}"`, 27 | changes: [ 28 | { 29 | fileName, 30 | textChanges: [ 31 | { 32 | newText, 33 | span: { start: 0, length: 0 }, 34 | }, 35 | ], 36 | }, 37 | ], 38 | }; 39 | }; 40 | 41 | function init(modules: { typescript: typeof ts_module }) { 42 | const ts = modules.typescript; 43 | 44 | /** normalize the parameter so we are sure is of type number */ 45 | function positionOrRangeToNumber( 46 | positionOrRange: number | ts_module.TextRange 47 | ): number { 48 | return typeof positionOrRange === "number" 49 | ? positionOrRange 50 | : (positionOrRange as ts_module.TextRange).pos; 51 | } 52 | 53 | /** from given position we find the child node that contains it */ 54 | const findChildContainingPosition = ( 55 | sourceFile: ts.SourceFile, 56 | position: number 57 | ): ts.Node | undefined => { 58 | const find = (node: ts.Node): ts.Node | undefined => { 59 | if (position >= node.getStart() && position <= node.getEnd()) { 60 | return ts.forEachChild(node, find) ?? node; 61 | } 62 | }; 63 | 64 | return find(sourceFile); 65 | }; 66 | 67 | function create(info: ts.server.PluginCreateInfo) { 68 | const namespaceConfig: NamespacesConfig = info.config.namespaces; 69 | 70 | const proxy: ts.LanguageService = Object.create(null); 71 | const oldLS = info.languageService; 72 | for (const k in oldLS) { 73 | (proxy)[k] = function () { 74 | return oldLS[k].apply(oldLS, arguments); 75 | }; 76 | } 77 | 78 | proxy.getCodeFixesAtPosition = ( 79 | fileName, 80 | start, 81 | end, 82 | errorCodes, 83 | formatOptions, 84 | preferences 85 | ) => { 86 | const prior = info.languageService.getCodeFixesAtPosition( 87 | fileName, 88 | start, 89 | end, 90 | errorCodes, 91 | formatOptions, 92 | preferences 93 | ); 94 | 95 | const sourceFile = info.languageService 96 | .getProgram() 97 | .getSourceFile(fileName); 98 | 99 | const nodeAtCursor = findChildContainingPosition( 100 | sourceFile, 101 | positionOrRangeToNumber({ pos: start, end }) 102 | ); 103 | 104 | const text = nodeAtCursor.getText(); 105 | 106 | if ( 107 | nodeAtCursor.kind === ts.SyntaxKind.Identifier && 108 | Object.keys(namespaceConfig).includes(text) 109 | ) { 110 | // Since we're using a codefix, if the namespace is already imported the code fix won't be suggested 111 | const codeAction = createCodeAction({ 112 | fileName, 113 | importPath: namespaceConfig[text].importPath, 114 | namespaceName: text, 115 | }); 116 | 117 | return [...prior, codeAction]; 118 | } 119 | 120 | return prior; 121 | }; 122 | 123 | proxy.getCompletionsAtPosition = (fileName, position, options) => { 124 | const prior = info.languageService.getCompletionsAtPosition( 125 | fileName, 126 | position, 127 | options 128 | ); 129 | 130 | const sourceFile = info.languageService 131 | .getProgram() 132 | .getSourceFile(fileName); 133 | 134 | const nodeAtCursor = findChildContainingPosition(sourceFile, position); 135 | const text = nodeAtCursor.getText(); 136 | 137 | const findExistingImport = ( 138 | sourceFile: ts.SourceFile, 139 | name: string 140 | ): ts.Node | undefined => { 141 | const find = (node: ts.Node): ts.Node | undefined => 142 | ts_module.isNamespaceImport(node) && node.name.getText() === name 143 | ? node 144 | : ts.forEachChild(node, find); 145 | 146 | return find(sourceFile); 147 | }; 148 | 149 | const extras: ts_module.CompletionEntry[] = []; 150 | 151 | for (const namespaceName of Object.keys(namespaceConfig)) { 152 | if ( 153 | namespaceName.startsWith(text) && 154 | findExistingImport(sourceFile, namespaceName) === undefined 155 | ) { 156 | const completion: ts_module.CompletionEntry = { 157 | name: namespaceName, 158 | // TODO: what does this do? 159 | // https://github.com/microsoft/TypeScript/blob/92af654a83c497eb35aed7d186b746c8ca4b88fb/src/services/completions.ts#L12 160 | sortText: "15", 161 | // TODO: what does this do? 162 | kind: ts_module.ScriptElementKind.variableElement, 163 | // TODO: what does this do? 164 | kindModifiers: "", 165 | // TODO: if we set this, completion doesn't show for some reason 166 | // hasAction: true, 167 | sourceDisplay: [ 168 | { 169 | kind: ts_module.SymbolDisplayPartKind[ 170 | ts_module.SymbolDisplayPartKind.text 171 | ], 172 | text: namespaceConfig[namespaceName].importPath, 173 | }, 174 | ], 175 | data: { 176 | exportName: namespaceName, 177 | fileName: namespaceConfig[namespaceName].importPath, 178 | // TODO: what does this do? 179 | moduleSpecifier: namespaceConfig[namespaceName].importPath, 180 | }, 181 | }; 182 | 183 | extras.push(completion); 184 | } 185 | } 186 | 187 | prior.entries = [...extras, ...prior.entries]; 188 | 189 | return prior; 190 | }; 191 | 192 | proxy.getCompletionEntryDetails = ( 193 | fileName, 194 | position, 195 | entryName, 196 | formatOptions, 197 | source, 198 | preferences, 199 | data 200 | ) => { 201 | for (const namespaceName of Object.keys(namespaceConfig)) { 202 | if ( 203 | entryName === namespaceName && 204 | // This is used to distinguish the auto import completion from other 205 | // completions (e.g. standard text completions) with the same entry 206 | // name. 207 | data !== undefined && 208 | data.fileName !== undefined 209 | ) { 210 | const codeAction: ts_module.CodeFixAction = createCodeAction({ 211 | fileName, 212 | importPath: data.fileName, 213 | namespaceName: data.exportName, 214 | }); 215 | 216 | return { 217 | name: namespaceName, 218 | codeActions: [codeAction], 219 | // TODO: what does this do? 220 | displayParts: [], 221 | // TODO: what does this do? 222 | kind: ts_module.ScriptElementKind.variableElement, 223 | // TODO: what does this do? 224 | kindModifiers: "", 225 | }; 226 | } 227 | } 228 | 229 | return info.languageService.getCompletionEntryDetails( 230 | fileName, 231 | position, 232 | entryName, 233 | formatOptions, 234 | source, 235 | preferences, 236 | data 237 | ); 238 | }; 239 | 240 | return proxy; 241 | } 242 | 243 | return { create }; 244 | } 245 | 246 | export = init; 247 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2016", 5 | "noImplicitAny": false, 6 | "sourceMap": false, 7 | "outDir": "dist" 8 | }, 9 | "exclude": ["./_extension/*"] 10 | } -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | typescript@^4.3.2: 6 | version "4.6.2" 7 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" 8 | integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== 9 | --------------------------------------------------------------------------------