├── .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 |
8 |
9 | As completion
10 |
11 |
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 |
--------------------------------------------------------------------------------