diff --git a/.gitignore b/.gitignore index 1418b12b..dda848b0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ src/configurationTypeCache.jsonc coverage *.lcov .nyc_output +.vscode-test-web logs *.log diff --git a/README.MD b/README.MD index 30267566..34d1f8fe 100644 --- a/README.MD +++ b/README.MD @@ -10,6 +10,8 @@ Add JSX elements to outline. It also makes sticky scroll works with your tags! Super recommended for react. Fragments are not rendered. +Also is not supported in the web. + ## **Completions Built Different** 90% work done in this extension highly improves completions experience! @@ -27,15 +29,22 @@ const callback = (arg) => {} callback -> callback(arg) ``` -### Clean Emmet +### Strict Emmet -(*enabled by default*) +(*enabled by default*) when react langs are in `emmet.excludeLanguages` + +Emmet that is active **only** inside JSX tags! + +You can force enable this by using `Enable Strict Emmet in JSX` command. + +*Why?* Issues it fixes: [query](https://github.com/microsoft/vscode/issues?q=sort%3Aupdated-desc+51537+150671+142978+119736). -You can turn off emmet integration in JSX and stable emmet suggestion will be *always* within JSX elements. +#### Optional Emmet Features -*Why?* +- cleanup input & textarea suggestions +- override `.` snippet -- supports only tag expansion for now, have 2 modes +Is not supported in the web for now. ### Array Method Snippets @@ -57,6 +66,12 @@ usersList.map // -> usersList.map((user) => ) ## Minor Useful Features +### Web Support + +> Note: when you open TS/JS file in the web for the first time you currently need to switch editors to make everything work! + +Web-only feature: `import` path resolution + ### Highlight non-function Methods (*enabled by default*) @@ -90,7 +105,7 @@ Removes `Symbol`, `caller`, `prototype` completions on function / classes. (*enabled by default*) -Appends *space* to almost all keywords e.g. `extends `, like WebStorm does. +Appends *space* to almost all keywords e.g. `const `, like WebStorm does. ### Patch `toString()` @@ -109,3 +124,7 @@ Patches `toString()` insert function snippet on number types to remove tabStop. Mark all TS code actions with `🔵`, so you can be sure they're coming from TypeScript, and not some other extension. ### Builtin CodeFix Fixes + +## Even Even More + +Please look at extension settings, as this extension has much more features than described here! diff --git a/buildTsPlugin.mjs b/buildTsPlugin.mjs index 6a78831a..213a181b 100644 --- a/buildTsPlugin.mjs +++ b/buildTsPlugin.mjs @@ -1,14 +1,15 @@ //@ts-check import buildTsPlugin from '@zardoy/vscode-utils/build/buildTypescriptPlugin.js' +import { analyzeMetafile } from 'esbuild' -const watch = process.argv[2] === '--watch' -await buildTsPlugin('typescript', undefined, undefined, { - watch, - logLevel: 'info', - sourcemap: watch, - enableBrowser: true, +const result = await buildTsPlugin('typescript', undefined, undefined, { + minify: !process.argv.includes('--watch'), + metafile: true, banner: { js: 'let ts', // js: 'const log = (...args) => console.log(...args.map(a => JSON.stringify(a)))', }, }) + +// @ts-ignore +// console.log(await analyzeMetafile(result.metafile)) diff --git a/integration/suite/completions.test.ts b/integration/suite/completions.test.ts index b521d8c2..5a1514b1 100644 --- a/integration/suite/completions.test.ts +++ b/integration/suite/completions.test.ts @@ -5,7 +5,7 @@ import { expect } from 'chai' // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error import { fromFixtures, prepareTsStart } from './utils' -describe('Completions', () => { +describe.skip('Completions', () => { const editor = () => vscode.window.activeTextEditor! before(async function () { diff --git a/integration/suite/jsx.test.ts b/integration/suite/jsx.test.ts index 6809c3b8..ac81a7f0 100644 --- a/integration/suite/jsx.test.ts +++ b/integration/suite/jsx.test.ts @@ -7,7 +7,7 @@ import { fromFixtures, prepareTsStart, replaceEditorText } from './utils' //@ts-ignore import { Configuration } from '../../src/configurationType' -describe('JSX Attributes', () => { +describe.skip('JSX Attributes', () => { const editor = () => vscode.window.activeTextEditor! const startPos = new vscode.Position(0, 0) diff --git a/package.json b/package.json index e606aca3..ad718cb0 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "license": "MIT", "preview": true, "contributes": { + "commands": [ + { + "command": "enableStrictEmmetInJsx", + "title": "Enable Strict Emmet in JSX", + "category": "TS Essentials JSX" + } + ], "typescriptServerPlugins": [ { "name": "typescript-essential-plugins", @@ -34,8 +41,8 @@ "scripts": { "start": "vscode-framework start", "build": "tsc && tsc -p typescript --noEmit && vscode-framework build && pnpm build-plugin", - "build-plugin": "node buildTsPlugin.mjs", - "watch-plugin": "pnpm build-plugin --watch", + "build-plugin": "node buildTsPlugin.mjs && node buildTsPlugin.mjs --browser", + "watch-plugin": "node buildTsPlugin.mjs --watch", "lint": "eslint src/**", "test": "pnpm test-plugin --run && pnpm integration-test", "test-plugin": "vitest --globals --dir typescript/test/", @@ -67,7 +74,7 @@ "@vscode/emmet-helper": "^2.8.4", "@vscode/test-electron": "^2.1.5", "@zardoy/utils": "^0.0.9", - "@zardoy/vscode-utils": "^0.0.32", + "@zardoy/vscode-utils": "^0.0.36", "chai": "^4.3.6", "chokidar": "^3.5.3", "chokidar-cli": "^3.0.0", @@ -85,7 +92,8 @@ "rambda": "^7.2.1", "require-from-string": "^2.0.2", "string-dedent": "^3.0.1", - "vscode-framework": "^0.0.18" + "vscode-framework": "^0.0.18", + "vscode-uri": "^3.0.6" }, "prettier": { "semi": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c8fb6f1..18b0e400 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: '@vscode/test-electron': ^2.1.5 '@zardoy/tsconfig': ^1.3.1 '@zardoy/utils': ^0.0.9 - '@zardoy/vscode-utils': ^0.0.32 + '@zardoy/vscode-utils': ^0.0.36 chai: ^4.3.6 chokidar: ^3.5.3 chokidar-cli: ^3.0.0 @@ -44,6 +44,7 @@ importers: vitest: ^0.15.1 vscode-framework: ^0.0.18 vscode-manifest: ^0.0.4 + vscode-uri: ^3.0.6 dependencies: '@types/chai': 4.3.3 '@types/glob': 8.0.0 @@ -53,7 +54,7 @@ importers: '@vscode/emmet-helper': 2.8.4 '@vscode/test-electron': 2.1.5 '@zardoy/utils': 0.0.9 - '@zardoy/vscode-utils': 0.0.32_cq2imf3xlo2d7oce7kuqydjknm + '@zardoy/vscode-utils': 0.0.36_cq2imf3xlo2d7oce7kuqydjknm chai: 4.3.6 chokidar: 3.5.3 chokidar-cli: 3.0.0 @@ -72,6 +73,7 @@ importers: require-from-string: 2.0.2 string-dedent: 3.0.1 vscode-framework: 0.0.18_w77hramgtzb6kbct6jmnygw2sq + vscode-uri: 3.0.6 devDependencies: '@milahu/patch-package-with-pnpm-support': 6.4.10 '@types/fs-extra': 9.0.13 @@ -796,7 +798,7 @@ packages: koa-static: 5.0.0 minimist: 1.2.5 playwright: 1.14.1 - vscode-uri: 3.0.3 + vscode-uri: 3.0.6 transitivePeerDependencies: - bufferutil - supports-color @@ -834,8 +836,8 @@ packages: type-fest: 2.19.0 dev: false - /@zardoy/vscode-utils/0.0.32_cq2imf3xlo2d7oce7kuqydjknm: - resolution: {integrity: sha512-7xwObwKMv5ezm7qb13PlVTtVxdsJq84/SGuQWJwME8BMq42zYp3VZRMcq0t6VUKB6QZjLz0Qoe3jnRtEIlluMQ==} + /@zardoy/vscode-utils/0.0.36_cq2imf3xlo2d7oce7kuqydjknm: + resolution: {integrity: sha512-AavLIqSwoZ/GVJx69AuXgzEKj67HSZoq26OuIiaMldnlZGwUYK+ZrtRX7swIZRZNHJnLMe2rrJHe4rh+hBba9Q==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true peerDependencies: @@ -862,13 +864,14 @@ packages: execa: 5.1.1 fs-extra: 10.1.0 lodash.throttle: 4.1.1 + modify-json-file: 1.2.2 rambda: 7.2.1 type-fest: 2.19.0 typed-jsonfile: 0.2.1 untildify: 4.0.0 vscode-framework: 0.0.18_w77hramgtzb6kbct6jmnygw2sq vscode-manifest: 0.0.8 - vscode-uri: 3.0.4 + vscode-uri: 3.0.6 dev: false /accepts/1.3.7: @@ -2822,7 +2825,7 @@ packages: dev: false /get-stream/2.3.1: - resolution: {integrity: sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=} + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} engines: {node: '>=0.10.0'} dependencies: object-assign: 4.1.1 @@ -3258,7 +3261,7 @@ packages: dev: false /is-stream/1.1.0: - resolution: {integrity: sha1-EtSj3U5o4Lec6428hBc66A2RykQ=} + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} dev: false @@ -4217,7 +4220,7 @@ packages: dependencies: graceful-fs: 4.2.10 retry: 0.12.0 - signal-exit: 3.0.6 + signal-exit: 3.0.7 dev: false /proxy-from-env/1.1.0: @@ -4532,10 +4535,6 @@ packages: object-inspect: 1.12.2 dev: false - /signal-exit/3.0.6: - resolution: {integrity: sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==} - dev: false - /signal-exit/3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: false @@ -5276,12 +5275,8 @@ packages: resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} dev: false - /vscode-uri/3.0.3: - resolution: {integrity: sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA==} - dev: false - - /vscode-uri/3.0.4: - resolution: {integrity: sha512-aEmKD6H8Sg8gaQAUrnadG0BMeWXtiWhRsj1a94n2FYsMkDpgnK7BRVzZjOUYIvkv2B+bp5Bmt4ImZCpYbnJwkg==} + /vscode-uri/3.0.6: + resolution: {integrity: sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ==} dev: false /vue-eslint-parser/8.3.0_eslint@8.7.0: diff --git a/src/configurationType.ts b/src/configurationType.ts index 544dad78..7c8a5175 100644 --- a/src/configurationType.ts +++ b/src/configurationType.ts @@ -105,7 +105,7 @@ export type Configuration = { 'markTsCodeFixes.character': string // TODO /** - * Reveal import statement as definition instead of real definition + * Reveal definition in import statement instead of real definition in another file * @default true * */ // 'importUpDefinition.enable': boolean @@ -119,10 +119,29 @@ export type Configuration = { * */ 'removeCodeFixes.codefixes': ('fixMissingMember' | 'fixMissingProperties' | 'fixMissingAttributes' | 'fixMissingFunctionDeclaration')[] /** - * Only tag support - * @default fakeEmmet - * */ - 'jsxEmmet.type': 'realEmmet' | 'fakeEmmet' | 'disabled' + * Use full-blown emmet in jsx/tsx files! + * Requires `jsxPseudoEmmet` be off and `emmet.excludeLanguages` to have `javascriptreact` and `typescriptreact` + * @default true + * */ + jsxEmmet: boolean + /** + * Override snippet inserted on `.` literally + * @default false + */ + 'jsxEmmet.dotOverride': string | false + /** + * We already change sorting of suggestions, but enabling this option will also make: + * - removing `id` from input suggestions + * - simplify textarea + * Doesn't change preview text for now! + * @default false + */ + 'jsxEmmet.modernize': boolean + /** + * Suggests only common tags such as div + * @default false + */ + jsxPseudoEmmet: boolean /** * Note: Sorting matters */ @@ -200,6 +219,12 @@ export type Configuration = { * @default false */ supportTsDiagnosticDisableComment: boolean + /** + * Adds special helpers completions in `{}` + * For example when you're trying to complete object props in array + * @default true + */ + // completionHelpers: boolean /** * Extend TypeScript outline! * Extend outline with: diff --git a/src/emmet.ts b/src/emmet.ts new file mode 100644 index 00000000..eb7df116 --- /dev/null +++ b/src/emmet.ts @@ -0,0 +1,132 @@ +import * as vscode from 'vscode' +import { getExtensionSetting, registerExtensionCommand, updateExtensionSetting } from 'vscode-framework' +import { EmmetResult } from '../typescript/src/ipcTypes' +import { sendCommand } from './sendCommand' + +export const registerEmmet = async () => { + if (process.env.PLATFORM !== 'web') { + const emmet = await import('@vscode/emmet-helper') + const reactLangs = ['javascriptreact', 'typescriptreact'] + vscode.languages.registerCompletionItemProvider( + reactLangs, + { + async provideCompletionItems(document, position, token, context) { + const emmetConfig = vscode.workspace.getConfiguration('emmet') + if (!emmetConfig.excludeLanguages.includes(document.languageId)) return + + const result = await sendCommand('emmet-completions', { document, position }) + if (!result) return + const offset = document.offsetAt(position) + const sendToEmmet = document.getText().slice(offset + result.emmetTextOffset, offset) + const emmetCompletions = emmet.doComplete( + { + getText: () => sendToEmmet, + languageId: 'typescriptreact', + lineCount: 1, + offsetAt: position => position.character, + positionAt: offset => ({ line: 0, character: offset }), + uri: '/', + version: 1, + }, + { line: 0, character: sendToEmmet.length }, + 'jsx', + getEmmetConfiguration(), + ) ?? { items: undefined } + const normalizedCompletions = (emmetCompletions?.items ?? []).map(({ label, insertTextFormat, textEdit, documentation }) => { + const { newText, range } = textEdit as any + return { + label, + insertText: newText, + documentation, + rangeLength: sendToEmmet.length - range.start.character, + } + }) + return { + items: + improveEmmetCompletions(normalizedCompletions)?.map(({ label, insertText, rangeLength, documentation, sortText }) => ({ + label: { label, description: 'EMMET' }, + // sortText is overrided if its a number + sortText: Number.isNaN(+sortText) ? '075' : sortText, + insertText: new vscode.SnippetString(insertText), + range: new vscode.Range(position.translate(0, -rangeLength), position), + documentation: documentation as string, + })) ?? [], + isIncomplete: true, + } + }, + }, + // eslint-disable-next-line unicorn/no-useless-spread + ...['!', '.', '}', '*', '$', ']', '/', '>', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ) + + registerExtensionCommand('enableStrictEmmetInJsx', async () => { + const emmetConfig = vscode.workspace.getConfiguration('emmet') + const emmetExcludedLangs: string[] = emmetConfig.excludeLanguages ?? [] + const addExcludeLangs = reactLangs.filter(lang => !emmetExcludedLangs.includes(lang)) + if (addExcludeLangs.length > 0) { + await vscode.workspace + .getConfiguration('emmet') + .update('excludeLanguages', [...emmetExcludedLangs, ...addExcludeLangs], vscode.ConfigurationTarget.Global) + void vscode.window.showInformationMessage(`Added to ${addExcludeLangs.join(',')} emmet.excludeLanguages`) + } + + await vscode.workspace.getConfiguration(process.env.IDS_PREFIX).update('jsxEmmet', true, vscode.ConfigurationTarget.Global) + await vscode.workspace.getConfiguration(process.env.IDS_PREFIX).update('jsxPseudoEmmet', false, vscode.ConfigurationTarget.Global) + }) + + // TODO: select wrap, matching, rename tag + } +} + +function getEmmetConfiguration() { + const syntax = 'jsx' + // TODO lang-overrides? + const emmetConfig = vscode.workspace.getConfiguration('emmet') + const syntaxProfiles = { ...emmetConfig.syntaxProfiles } + const preferences = { ...emmetConfig.preferences } + // jsx, xml and xsl syntaxes need to have self closing tags unless otherwise configured by user + if (['jsx', 'xml', 'xsl'].includes(syntax)) { + syntaxProfiles[syntax] = syntaxProfiles[syntax] || {} + if (typeof syntaxProfiles[syntax] === 'object' && !syntaxProfiles[syntax].selfClosingStyle) { + syntaxProfiles[syntax] = { + ...syntaxProfiles[syntax], + selfClosingStyle: syntax === 'jsx' ? 'xhtml' : 'xml', + } + } + } + + return { + preferences, + showExpandedAbbreviation: emmetConfig.showExpandedAbbreviation, + showAbbreviationSuggestions: emmetConfig.showAbbreviationSuggestions, + syntaxProfiles, + variables: emmetConfig.variables, + excludeLanguages: [], + showSuggestionsAsSnippets: emmetConfig.showSuggestionsAsSnippets, + } +} + +const improveEmmetCompletions = >(items: T[] | undefined) => { + if (!items) return + // TODO-low make to tw= by default when twin.macro is installed? + const dotSnippetOverride = getExtensionSetting('jsxEmmet.dotOverride') + const modernEmmet = getExtensionSetting('jsxEmmet.modernize') + + return items.map(item => { + const { label } = item + if (label === '.' && typeof dotSnippetOverride === 'string') item.insertText = dotSnippetOverride + // change sorting to most used + if (['div', 'b'].includes(label)) item.sortText = '070' + if (label.startsWith('btn')) item.sortText = '073' + if (modernEmmet) { + // remove id from input suggestions + if (label === 'inp' || label.startsWith('input:password')) { + item.insertText = item.insertText.replace(/ id="\${\d}"/, '') + } + + if (label === 'textarea') item.insertText = `` + } + + return item + }) +} diff --git a/src/experimentalPostfixes.ts b/src/experimentalPostfixes.ts new file mode 100644 index 00000000..4b52bcfe --- /dev/null +++ b/src/experimentalPostfixes.ts @@ -0,0 +1,40 @@ +import * as vscode from 'vscode' +import { defaultJsSupersetLangs } from '@zardoy/vscode-utils/build/langs' +import { getExtensionSetting } from 'vscode-framework' +import { PostfixCompletion } from '../typescript/src/ipcTypes' +import { sendCommand } from './sendCommand' + +export default () => { + vscode.languages.registerCompletionItemProvider( + defaultJsSupersetLangs, + { + async provideCompletionItems(document, position, token, context) { + if (!position.character) return + if (!getExtensionSetting('experimentalPostfixes.enable')) return + const beforeDotPos = document.getWordRangeAtPosition(position)?.start ?? position + if (document.getText(new vscode.Range(beforeDotPos, beforeDotPos.translate(0, -1))) !== '.') return + const postfixes = await sendCommand('getPostfixes', { document, position }) + const disablePostfixes = getExtensionSetting('experimentalPostfixes.disablePostfixes') + return postfixes + ?.filter(({ label }) => !disablePostfixes.includes(label)) + .map( + ({ label, insertText }): vscode.CompletionItem => ({ + label, + insertText, + sortText: '07', + range: new vscode.Range(beforeDotPos.translate(0, -1), position), + filterText: document.getText(new vscode.Range(beforeDotPos.translate(0, -1), position)) + label, + kind: vscode.CompletionItemKind.Event, + // additionalTextEdits: [ + // vscode.TextEdit.replace( + // new vscode.Range(document.positionAt(replacement[0]), replacement[1] ? document.positionAt(replacement[1]) : position), + // insertText, + // ), + // ], + }), + ) + }, + }, + '.', + ) +} diff --git a/src/extension.ts b/src/extension.ts index 3d3dc5ed..3f3e4899 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,17 @@ import * as vscode from 'vscode' import { defaultJsSupersetLangs } from '@zardoy/vscode-utils/build/langs' import { getActiveRegularEditor } from '@zardoy/vscode-utils' -import { getExtensionSetting, extensionCtx, getExtensionSettingId, getExtensionCommandId } from 'vscode-framework' +import { extensionCtx, getExtensionSettingId, getExtensionCommandId } from 'vscode-framework' import { pickObj } from '@zardoy/utils' -import { PostfixCompletion, TriggerCharacterCommand } from '../typescript/src/ipcTypes' import { Configuration } from './configurationType' +import webImports from './webImports' +import { sendCommand } from './sendCommand' +import { registerEmmet } from './emmet' +import experimentalPostfixes from './experimentalPostfixes' export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted }) => { + let webWaitingForConfigSync = false + const syncConfig = () => { console.log('sending configure request for typescript-essential-plugins') const config = vscode.workspace.getConfiguration().get(process.env.IDS_PREFIX!) @@ -21,12 +26,16 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted } tsApi.configurePlugin('typescript-essential-plugins', config) + + if (process.env.PLATFORM === 'web') { + webWaitingForConfigSync = true + } } vscode.workspace.onDidChangeConfiguration(async ({ affectsConfiguration }) => { if (affectsConfiguration(process.env.IDS_PREFIX!)) { syncConfig() - if (affectsConfiguration(getExtensionSettingId('patchOutline'))) { + if (process.env.PLATFORM === 'node' && affectsConfiguration(getExtensionSettingId('patchOutline'))) { await vscode.commands.executeCommand('typescript.restartTsServer') void vscode.window.showWarningMessage('Outline will be updated after text changes or window reload') } @@ -67,84 +76,40 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted } }) - type SendCommandData = { - position: vscode.Position - document: vscode.TextDocument - } - const sendCommand = async (command: TriggerCharacterCommand, sendCommandDataArg: SendCommandData) => { - const { document, position } = ((): SendCommandData => { - if (sendCommandDataArg) return sendCommandDataArg - const editor = getActiveRegularEditor()! - return { - document: editor.document, - position: editor.selection.active, - } - })() - console.time(`request ${command}`) - try { - const result = (await vscode.commands.executeCommand('typescript.tsserverRequest', 'completionInfo', { - _: '%%%', - file: document.uri.fsPath, - line: position.line + 1, - offset: position.character, - triggerCharacter: command, - })) as any - if (!result || !result.body) return - return result.body - } catch (err) { - console.error(err) - } finally { - console.timeEnd(`request ${command}`) - } - } - - vscode.languages.registerCompletionItemProvider( - defaultJsSupersetLangs, - { - async provideCompletionItems(document, position, token, context) { - if (!position.character) return - if (!getExtensionSetting('experimentalPostfixes.enable')) return - const beforeDotPos = document.getWordRangeAtPosition(position)?.start ?? position - if (document.getText(new vscode.Range(beforeDotPos, beforeDotPos.translate(0, -1))) !== '.') return - const result = await sendCommand('getPostfixes', { document, position }) - const disablePostfixes = getExtensionSetting('experimentalPostfixes.disablePostfixes') - // eslint-disable-next-line prefer-destructuring - const typescriptEssentialsResponse: PostfixCompletion[] = result.typescriptEssentialsResponse - if (!typescriptEssentialsResponse) return - return typescriptEssentialsResponse - .filter(({ label }) => !disablePostfixes.includes(label)) - .map( - ({ label, insertText }): vscode.CompletionItem => ({ - label, - insertText, - sortText: '07', - range: new vscode.Range(beforeDotPos.translate(0, -1), position), - filterText: document.getText(new vscode.Range(beforeDotPos.translate(0, -1), position)) + label, - kind: vscode.CompletionItemKind.Event, - // additionalTextEdits: [ - // vscode.TextEdit.replace( - // new vscode.Range(document.positionAt(replacement[0]), replacement[1] ? document.positionAt(replacement[1]) : position), - // insertText, - // ), - // ], - }), - ) - }, - }, - '.', - ) - type RequestOptions = Partial<{ offset: number }> - vscode.commands.registerCommand(getExtensionCommandId('getNodeAtPosition' as never), async (_, { offset }: RequestOptions = {}) => { + vscode.commands.registerCommand(getExtensionCommandId('getNodeAtPosition' as never), async ({ offset }: RequestOptions = {}) => { const { activeTextEditor } = vscode.window if (!activeTextEditor) return const { document } = activeTextEditor - const { typescriptEssentialsResponse: data } = - (await sendCommand('nodeAtPosition', { document, position: offset ? document.positionAt(offset) : activeTextEditor.selection.active })) ?? {} - return data + return sendCommand('nodeAtPosition', { document, position: offset ? document.positionAt(offset) : activeTextEditor.selection.active }) }) + + if (process.env.PLATFORM === 'web') { + const possiblySyncConfig = async () => { + const { activeTextEditor } = vscode.window + if (!activeTextEditor || !vscode.languages.match(defaultJsSupersetLangs, activeTextEditor.document)) return + if (!webWaitingForConfigSync) return + // webWaitingForConfigSync = false + const config = vscode.workspace.getConfiguration().get(process.env.IDS_PREFIX!) + void sendCommand(`updateConfig${JSON.stringify(config)}` as any) + } + + vscode.window.onDidChangeActiveTextEditor(possiblySyncConfig) + void possiblySyncConfig() + } + + experimentalPostfixes() + void registerEmmet() + webImports() + + // registerActiveDevelopmentCommand(async () => { + // const items: vscode.DocumentSymbol[] = await vscode.commands.executeCommand( + // 'vscode.executeDocumentSymbolProvider', + // vscode.Uri.file(...), + // ) + // }) } export const activate = async () => { diff --git a/src/sendCommand.ts b/src/sendCommand.ts new file mode 100644 index 00000000..a8555566 --- /dev/null +++ b/src/sendCommand.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode' +import { getActiveRegularEditor } from '@zardoy/vscode-utils' +import { TriggerCharacterCommand } from '../typescript/src/ipcTypes' + +type SendCommandData = { + position: vscode.Position + document: vscode.TextDocument +} +export const sendCommand = async (command: TriggerCharacterCommand, sendCommandDataArg?: SendCommandData): Promise => { + const { + document: { uri }, + position, + } = ((): SendCommandData => { + if (sendCommandDataArg) return sendCommandDataArg + const editor = getActiveRegularEditor()! + return { + document: editor.document, + position: editor.selection.active, + } + })() + + console.time(`request ${command}`) + let requestFile = uri.fsPath + if (uri.scheme !== 'file') requestFile = `^/${uri.scheme}/${uri.authority || 'ts-nul-authority'}/${uri.path.replace(/^\//, '')}` + try { + const result = (await vscode.commands.executeCommand('typescript.tsserverRequest', 'completionInfo', { + _: '%%%', + file: requestFile, + line: position.line + 1, + offset: position.character + 1, + triggerCharacter: command, + })) as any + return result?.body?.typescriptEssentialsResponse + } catch (err) { + console.error(err) + } finally { + console.timeEnd(`request ${command}`) + } + + return undefined +} diff --git a/src/webImports.ts b/src/webImports.ts new file mode 100644 index 00000000..b17f1fde --- /dev/null +++ b/src/webImports.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode' +import { defaultJsSupersetLangsWithVue } from '@zardoy/vscode-utils/build/langs' +import { firstExists } from '@zardoy/vscode-utils/build/fs' +import { Utils } from 'vscode-uri' + +export default () => { + if (process.env.PLATFORM !== 'web') return + vscode.languages.registerDefinitionProvider(defaultJsSupersetLangsWithVue, { + async provideDefinition(document, position, token) { + const importData = getModuleFromLine(document.lineAt(position).text) + if (!importData) return + const [beforeLength, importPath] = importData + // +1 for quote + const startPos = position.with(undefined, beforeLength + 1) + const endPos = startPos.translate(0, importPath.length) + const selectionRange = new vscode.Range(startPos, endPos) + if (!selectionRange.contains(position)) return + const importUri = vscode.Uri.joinPath(Utils.dirname(document.uri), importPath) + // for now not going to make impl more complex as it would be removed soon anyway in favor of native TS support + const extensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '.vue'] + const targetUri = await firstExists( + ['', ...extensions, ...extensions.map(ext => `/index${ext}`)].map(ext => { + const uri = importUri.with({ + path: `${importUri.path}${ext}`, + }) + return { uri, name: uri, isFile: ext === '' || undefined } + }), + ) + if (!targetUri) return + const startFilePos = new vscode.Position(0, 0) + return [ + { + targetUri, + targetRange: new vscode.Range(startFilePos, startFilePos), + originSelectionRange: selectionRange, + }, + ] as vscode.LocationLink[] + }, + }) +} + +const getModuleFromLine = (line: string) => { + // I just copy-pasted implementation from npm-rapid-ready, doesn't matter if it works in 99.99% cases + const regexs = [/(import .*)(['"].*['"])/, /(} from )(['"].*['"])/] + for (const regex of regexs) { + const result = regex.exec(line) + if (!result) continue + return [result[1]!.length, result[2]!.slice(1, -1)] as const + } + + return undefined +} diff --git a/typescript/src/codeFixes.ts b/typescript/src/codeFixes.ts index 65aa4a80..9bdcdf01 100644 --- a/typescript/src/codeFixes.ts +++ b/typescript/src/codeFixes.ts @@ -1,11 +1,15 @@ import type tslib from 'typescript/lib/tsserverlibrary' +import addMissingProperties from './codeFixes/addMissingProperties' import { GetConfig } from './types' -import { getIndentFromPos } from './utils' +import { findChildContainingPosition, getIndentFromPos } from './utils' + +// codeFixes that I managed to put in files +const externalCodeFixes = [addMissingProperties] export default (proxy: ts.LanguageService, languageService: ts.LanguageService, c: GetConfig) => { proxy.getCodeFixesAtPosition = (fileName, start, end, errorCodes, formatOptions, preferences) => { let prior = languageService.getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences) - // fix builtin codefixes/refactorings + // #region fix builtin codefixes/refactorings prior.forEach(fix => { if (fix.fixName === 'fixConvertConstToLet') { const { start, length } = fix.changes[0]!.textChanges[0]!.span @@ -15,15 +19,24 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService, } return fix }) - const diagnostics = proxy.getSemanticDiagnostics(fileName) + // #endregion + + const semanticDiagnostics = languageService.getSemanticDiagnostics(fileName) + const syntacicDiagnostics = languageService.getSyntacticDiagnostics(fileName) // https://github.com/Microsoft/TypeScript/blob/v4.5.5/src/compiler/diagnosticMessages.json#L458 - const appliableErrorCode = [1156, 1157].find(code => errorCodes.includes(code)) - if (appliableErrorCode) { + const findDiagnosticByCode = (codes: number[]) => { + const errorCode = codes.find(code => errorCodes.includes(code)) + if (!errorCode) return + const diagnosticPredicate = ({ code, start: localStart }) => code === errorCode && localStart === start + return syntacicDiagnostics.find(diagnosticPredicate) || semanticDiagnostics.find(diagnosticPredicate) + } + + const wrapBlockDiagnostics = findDiagnosticByCode([1156, 1157]) + if (wrapBlockDiagnostics) { const program = languageService.getProgram() const sourceFile = program!.getSourceFile(fileName)! const startIndent = getIndentFromPos(ts, sourceFile, end) - const diagnostic = diagnostics.find(({ code }) => code === appliableErrorCode)! prior = [ ...prior, { @@ -33,8 +46,8 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService, { fileName, textChanges: [ - { span: { start: diagnostic.start!, length: 0 }, newText: `{\n${startIndent}\t` }, - { span: { start: diagnostic.start! + diagnostic.length!, length: 0 }, newText: `\n${startIndent}}` }, + { span: { start: wrapBlockDiagnostics.start!, length: 0 }, newText: `{\n${startIndent}\t` }, + { span: { start: wrapBlockDiagnostics.start! + wrapBlockDiagnostics.length!, length: 0 }, newText: `\n${startIndent}}` }, ], }, ], @@ -42,6 +55,17 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService, ] } + const sourceFile = languageService.getProgram()?.getSourceFile(fileName)! + for (let codeFix of externalCodeFixes) { + const diagnostic = findDiagnosticByCode(codeFix.codes) + if (!diagnostic) continue + const startNode = findChildContainingPosition(ts, sourceFile, diagnostic.start!)! + const suggestedCodeFix = codeFix.provideFix(diagnostic, startNode, sourceFile, languageService) + if (!suggestedCodeFix) continue + prior = [suggestedCodeFix, ...prior] + } + + // TODO add our ids to enum of this setting if (c('removeCodeFixes.enable')) { const toRemove = c('removeCodeFixes.codefixes') prior = prior.filter(({ fixName }) => !toRemove.includes(fixName as any)) diff --git a/typescript/src/codeFixes/addMissingProperties.ts b/typescript/src/codeFixes/addMissingProperties.ts new file mode 100644 index 00000000..01290bdc --- /dev/null +++ b/typescript/src/codeFixes/addMissingProperties.ts @@ -0,0 +1,41 @@ +import { CodeFixInterface } from './codeFixInterface' + +export default { + codes: [2339], + provideFix(diagnostic, startNode, sourceFile, languageService) { + if (ts.isIdentifier(startNode) && ts.isObjectBindingPattern(startNode.parent.parent) && ts.isParameter(startNode.parent.parent.parent)) { + const param = startNode.parent.parent.parent + // special react pattern + if (ts.isArrowFunction(param.parent) && ts.isVariableDeclaration(param.parent.parent)) { + const variableDecl = param.parent.parent + if (variableDecl.type?.getText().match(/(React\.)?FC/)) { + // handle interface + } + } + // general patterns + if (param.type && ts.isTypeLiteralNode(param.type) && param.type.members) { + const hasMembers = param.type.members.length !== 0 + const insertPos = param.type.members.at(-1)?.end ?? param.type.end - 1 + const insertComma = hasMembers && sourceFile.getFullText().slice(insertPos - 1, insertPos) !== ',' + let insertText = startNode.escapedText as string + if (insertComma) insertText = `, ${insertText}` + return { + description: 'Declare missing property', + fixName: 'declareMissingProperty', + changes: [ + { + fileName: sourceFile.fileName, + textChanges: [ + { + span: { length: 0, start: insertPos }, + newText: insertText, + }, + ], + }, + ], + } + } + } + return + }, +} as CodeFixInterface diff --git a/typescript/src/codeFixes/codeFixInterface.ts b/typescript/src/codeFixes/codeFixInterface.ts new file mode 100644 index 00000000..b96cfdac --- /dev/null +++ b/typescript/src/codeFixes/codeFixInterface.ts @@ -0,0 +1,5 @@ +export interface CodeFixInterface { + codes: number[] + description?: string + provideFix(diagnostic: ts.Diagnostic, startNode: ts.Node, sourceFile: ts.SourceFile, languageService: ts.LanguageService): ts.CodeFixAction | undefined +} diff --git a/typescript/src/completions/arrayMethods.ts b/typescript/src/completions/arrayMethods.ts index a815f91f..506e3d0f 100644 --- a/typescript/src/completions/arrayMethods.ts +++ b/typescript/src/completions/arrayMethods.ts @@ -2,44 +2,45 @@ import { GetConfig } from '../types' import { findChildContainingPosition, getLineTextBeforePos } from '../utils' import { singular } from 'pluralize' -export default (entries: ts.CompletionEntry[], _node: ts.Node | undefined, position: number, sourceFile: ts.SourceFile, c: GetConfig): ts.CompletionEntry[] => { - if (!c('arrayMethodsSnippets.enable')) return entries +const arrayMethodsToPatch = [ + 'forEach', + 'map', + 'flatMap', + 'filter', + 'find', + 'findIndex', + // 'reduce', + // 'reduceRight', + 'some', + 'every', +] + +export default (entries: ts.CompletionEntry[], position: number, sourceFile: ts.SourceFile, c: GetConfig): ts.CompletionEntry[] | undefined => { + if (!c('arrayMethodsSnippets.enable')) return /** Methods to patch */ - const arrayMethods = [ - 'forEach', - 'map', - 'flatMap', - 'filter', - 'find', - 'findIndex', - // 'reduce', - // 'reduceRight', - 'some', - 'every', - ] - const fullText = sourceFile.getText() - if (fullText.slice(position, position + 1) === '(') return entries - const isSeemsArray = arrayMethods.every(comparingName => - entries.some( - ({ name, isSnippet, kind }) => name.replace(/^★ /, '') === comparingName && !isSnippet && kind === ts.ScriptElementKind.memberFunctionElement, - ), - ) - if (!isSeemsArray) return entries + + const fullText = sourceFile.getFullText() + if (fullText.slice(position, position + 1) === '(') return + const seemsArray = isArrayLike(entries) + if (!seemsArray) return + const lineTextBefore = getLineTextBeforePos(sourceFile, position) const postfixRemoveLength = /\.\w*$/.exec(lineTextBefore)?.[0]?.length - if (postfixRemoveLength === undefined) return entries + if (postfixRemoveLength === undefined) return const nodeBeforeDot = findChildContainingPosition(ts, sourceFile, position - postfixRemoveLength - 1) - if (!nodeBeforeDot) return entries + if (!nodeBeforeDot) return + const cleanSourceText = getItemNameFromNode(nodeBeforeDot)?.replace(/^(?:all)?(.+?)(?:List)?$/, '$1') let inferredName = cleanSourceText && singular(cleanSourceText) const defaultItemName = c('arrayMethodsSnippets.defaultItemName') // both can be undefined if (inferredName === cleanSourceText) { - if (defaultItemName === false) return entries + if (defaultItemName === false) return inferredName = defaultItemName } + return entries.map(entry => { - if (!arrayMethods.includes(entry.name.replace(/^★ /, ''))) return entry + if (!arrayMethodsToPatch.includes(entry.name.replace(/^★ /, ''))) return entry const arrayItemSnippet = c('arrayMethodsSnippets.addArgTabStop') ? `(\${2:${inferredName}})` : inferredName let insertInnerSnippet = `${arrayItemSnippet} => $3` if (c('arrayMethodsSnippets.addOuterTabStop')) insertInnerSnippet = `\${1:${insertInnerSnippet}}` @@ -62,3 +63,11 @@ const getItemNameFromNode = (node: ts.Node) => { } return undefined } + +export const isArrayLike = (entries: ts.CompletionEntry[]) => { + return arrayMethodsToPatch.every(comparingName => + entries.some( + ({ name, isSnippet, kind }) => name.replace(/^★ /, '') === comparingName && !isSnippet && kind === ts.ScriptElementKind.memberFunctionElement, + ), + ) +} diff --git a/typescript/src/completions/objectLiteralHelpers.ts b/typescript/src/completions/objectLiteralHelpers.ts new file mode 100644 index 00000000..171227ce --- /dev/null +++ b/typescript/src/completions/objectLiteralHelpers.ts @@ -0,0 +1,18 @@ +import { isArrayLike } from './arrayMethods' + +// currently WIP +export default (node: ts.Node, entries: ts.CompletionEntry[]): ts.CompletionEntry[] | undefined => { + if (ts.isObjectLiteralExpression(node) && isArrayLike(entries)) { + return [ + { + name: '(array)', + kind: ts.ScriptElementKind.label, + sortText: '07', + insertText: '[]', + labelDetails: { detail: ' change {} to []' }, + }, + ...entries, + ] + } + return +} diff --git a/typescript/src/completionsAtPosition.ts b/typescript/src/completionsAtPosition.ts index 057ef4e2..2cbc1245 100644 --- a/typescript/src/completionsAtPosition.ts +++ b/typescript/src/completionsAtPosition.ts @@ -1,6 +1,6 @@ import _ from 'lodash' import type tslib from 'typescript/lib/tsserverlibrary' -import * as emmet from '@vscode/emmet-helper' +// import * as emmet from '@vscode/emmet-helper' import isInBannedPosition from './completions/isInBannedPosition' import { GetConfig } from './types' import { findChildContainingPosition } from './utils' @@ -9,6 +9,8 @@ import fixPropertiesSorting from './completions/fixPropertiesSorting' import { isGoodPositionBuiltinMethodCompletion } from './completions/isGoodPositionMethodCompletion' import improveJsxCompletions from './completions/jsxAttributes' import arrayMethods from './completions/arrayMethods' +import prepareTextForEmmet from './specialCommands/prepareTextForEmmet' +import objectLiteralHelpers from './completions/objectLiteralHelpers' export type PrevCompletionMap = Record @@ -38,13 +40,13 @@ export const getCompletionsAtPosition = ( return true } const node = findChildContainingPosition(ts, sourceFile, position) + /** node that is one character behind + * useful as in most cases we work with node that is behind the cursor */ const leftNode = findChildContainingPosition(ts, sourceFile, position - 1) if (['.jsx', '.tsx'].some(ext => fileName.endsWith(ext))) { // #region JSX tag improvements if (node) { const { SyntaxKind } = ts - const emmetSyntaxKinds = [SyntaxKind.JsxFragment, SyntaxKind.JsxElement, SyntaxKind.JsxText] - const emmetClosingSyntaxKinds = [SyntaxKind.JsxClosingElement, SyntaxKind.JsxClosingFragment] // TODO maybe allow fragment? const correntComponentSuggestionsKinds = [SyntaxKind.JsxOpeningElement, SyntaxKind.JsxSelfClosingElement] const nodeText = node.getFullText().slice(0, position - node.pos) @@ -61,66 +63,27 @@ export const getCompletionsAtPosition = ( } // #endregion + // #region Fake emmet if ( - c('jsxEmmet.type') !== 'disabled' && - (emmetSyntaxKinds.includes(node.kind) || /* Just before closing tag */ (emmetClosingSyntaxKinds.includes(node.kind) && nodeText.length === 0)) + c('jsxPseudoEmmet') && + leftNode && + prepareTextForEmmet(fileName, leftNode, sourceFile, position, languageService) !== false && + ensurePrior() && + prior ) { - // const { textSpan } = proxy.getSmartSelectionRange(fileName, position) - // let existing = scriptSnapshot.getText(textSpan.start, textSpan.start + textSpan.length) - // if (existing.includes('\n')) existing = '' - if (ensurePrior() && prior) { - // if (existing.startsWith('.')) { - // const className = existing.slice(1) - // prior.entries.push({ - // kind: typescript.ScriptElementKind.label, - // name: className, - // sortText: '!5', - // insertText: `
$1
`, - // isSnippet: true, - // }) - // } else if (!existing[0] || existing[0].match(/\w/)) { - if (c('jsxEmmet.type') === 'realEmmet') { - const sendToEmmet = nodeText.split(' ').at(-1)! - const emmetCompletions = emmet.doComplete( - { - getText: () => sendToEmmet, - languageId: 'html', - lineCount: 1, - offsetAt: position => position.character, - positionAt: offset => ({ line: 0, character: offset }), - uri: '/', - version: 1, - }, - { line: 0, character: sendToEmmet.length }, - 'html', - {}, - ) ?? { items: [] } - for (const completion of emmetCompletions.items) - prior.entries.push({ - kind: ts.ScriptElementKind.label, - name: completion.label.slice(1), - sortText: '!5', - // insertText: `${completion.label.slice(1)} ${completion.textEdit?.newText}`, - insertText: completion.textEdit?.newText, - isSnippet: true, - sourceDisplay: completion.detail !== undefined ? [{ kind: 'text', text: completion.detail }] : undefined, - // replacementSpan: { start: position - 5, length: 5 }, - }) - } else { - const tags = c('jsxPseudoEmmet.tags') - for (let [tag, value] of Object.entries(tags)) { - if (value === true) value = `<${tag}>$1` - prior.entries.push({ - kind: ts.ScriptElementKind.label, - name: tag, - sortText: '!5', - insertText: value, - isSnippet: true, - }) - } - } + const tags = c('jsxPseudoEmmet.tags') + for (let [tag, value] of Object.entries(tags)) { + if (value === true) value = `<${tag}>$1` + prior.entries.push({ + kind: ts.ScriptElementKind.label, + name: tag, + sortText: '!5', + insertText: value, + isSnippet: true, + }) } } + // #endregion } } const addSignatureAccessCompletions = prior?.entries.filter(({ kind }) => kind !== ts.ScriptElementKind.warning).length @@ -156,6 +119,8 @@ export const getCompletionsAtPosition = ( }) } + // if (c('completionHelpers') && node) prior.entries = objectLiteralHelpers(node, prior.entries) ?? prior.entries + if (c('patchToString.enable')) { // const indexToPatch = arrayMoveItemToFrom( // prior.entries, @@ -211,7 +176,7 @@ export const getCompletionsAtPosition = ( }) } - prior.entries = arrayMethods(prior.entries, node, position, sourceFile, c) + prior.entries = arrayMethods(prior.entries, position, sourceFile, c) ?? prior.entries if (c('improveJsxCompletions') && leftNode) prior.entries = improveJsxCompletions(prior.entries, leftNode, position, sourceFile, c('jsxCompletionsMap')) diff --git a/typescript/src/definitions.ts b/typescript/src/definitions.ts index c7e366c8..3b8ca98b 100644 --- a/typescript/src/definitions.ts +++ b/typescript/src/definitions.ts @@ -1,10 +1,16 @@ -import type tslib from 'typescript/lib/tsserverlibrary' import { GetConfig } from './types' export default (proxy: ts.LanguageService, info: ts.server.PluginCreateInfo, c: GetConfig) => { proxy.getDefinitionAndBoundSpan = (fileName, position) => { const prior = info.languageService.getDefinitionAndBoundSpan(fileName, position) if (!prior) return + if (__WEB__) + // let extension handle it + // TODO failedAliasResolution + prior.definitions = prior.definitions?.filter(def => { + return !def.unverified || def.fileName === fileName + }) + // used after check const firstDef = prior.definitions![0]! if ( diff --git a/typescript/src/getPatchedNavTree.ts b/typescript/src/getPatchedNavTree.ts index fa7e1b9f..b1eb847a 100644 --- a/typescript/src/getPatchedNavTree.ts +++ b/typescript/src/getPatchedNavTree.ts @@ -1,11 +1,12 @@ import type tslib from 'typescript/lib/tsserverlibrary' -import requireFromString from 'require-from-string' +import { nodeModules } from './utils' +// uses at testing only declare const __TS_SEVER_PATH__: string | undefined const getPatchedNavModule = (ts: typeof tslib) => { const tsServerPath = typeof __TS_SEVER_PATH__ !== 'undefined' ? __TS_SEVER_PATH__ : require.main!.filename - const mainScript = require('fs').readFileSync(tsServerPath, 'utf8') as string + const mainScript = nodeModules!.fs.readFileSync(tsServerPath, 'utf8') as string const startIdx = mainScript.indexOf('var NavigationBar;') const ph = '(ts.NavigationBar = {}));' const lines = mainScript.slice(startIdx, mainScript.indexOf(ph) + ph.length).split(/\r?\n/) @@ -44,7 +45,7 @@ const getPatchedNavModule = (ts: typeof tslib) => { lines.splice(addTypeIndex + linesOffset, removeLines, ...(addString ? [addString] : [])) } } - const getModule = requireFromString('module.exports = (ts, getNameFromJsxTag) => {' + lines.join('\n') + 'return NavigationBar;}') + const getModule = nodeModules!.requireFromString('module.exports = (ts, getNameFromJsxTag) => {' + lines.join('\n') + 'return NavigationBar;}') const getNameFromJsxTag = (node: ts.JsxSelfClosingElement | ts.JsxOpeningElement) => { const { attributes: { properties }, diff --git a/typescript/src/globals.d.ts b/typescript/src/globals.d.ts index 53da7dd9..48b7a56f 100644 --- a/typescript/src/globals.d.ts +++ b/typescript/src/globals.d.ts @@ -1,2 +1,4 @@ // prvided by esbuild in buildTsPlugin.mjs declare let ts: typeof import('typescript/lib/tsserverlibrary') + +declare let __WEB__: boolean diff --git a/typescript/src/index.ts b/typescript/src/index.ts index ed24baf4..a8c6d341 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -11,12 +11,12 @@ import { oneOf } from '@zardoy/utils' import { isGoodPositionMethodCompletion } from './completions/isGoodPositionMethodCompletion' import { getParameterListParts } from './completions/snippetForFunctionCall' import { getNavTreeItems } from './getPatchedNavTree' -import { join } from 'path' import decorateCodeActions from './codeActions/decorateProxy' import decorateSemanticDiagnostics from './semanticDiagnostics' import decorateCodeFixes from './codeFixes' import decorateReferences from './references' import handleSpecialCommand from './specialCommands/handle' +import decorateDefinitions from './definitions' const thisPluginMarker = Symbol('__essentialPluginsMarker__') @@ -45,6 +45,11 @@ export = ({ typescript }: { typescript: typeof ts }) => { let prevCompletionsMap: PrevCompletionMap // eslint-disable-next-line complexity proxy.getCompletionsAtPosition = (fileName, position, options) => { + const updateConfigCommand = 'updateConfig' + if (options?.triggerCharacter?.startsWith(updateConfigCommand)) { + _configuration = JSON.parse(options.triggerCharacter.slice(updateConfigCommand.length)) + return { entries: [] } + } const specialCommandResult = options?.triggerCharacter ? handleSpecialCommand(info, fileName, position, options.triggerCharacter as TriggerCharacterCommand, _configuration) : undefined @@ -141,14 +146,17 @@ export = ({ typescript }: { typescript: typeof ts }) => { decorateCodeActions(proxy, info.languageService, c) decorateCodeFixes(proxy, info.languageService, c) decorateSemanticDiagnostics(proxy, info, c) + decorateDefinitions(proxy, info, c) decorateReferences(proxy, info.languageService, c) - // dedicated syntax server (which is enabled by default), which fires navtree doesn't seem to receive onConfigurationChanged - // so we forced to communicate via fs - const config = JSON.parse(ts.sys.readFile(join(__dirname, '../../plugin-config.json'), 'utf8') ?? '{}') - proxy.getNavigationTree = fileName => { - if (c('patchOutline') || config.patchOutline) return getNavTreeItems(ts, info, fileName) - return info.languageService.getNavigationTree(fileName) + if (!__WEB__) { + // dedicated syntax server (which is enabled by default), which fires navtree doesn't seem to receive onConfigurationChanged + // so we forced to communicate via fs + const config = JSON.parse(ts.sys.readFile(require('path').join(__dirname, '../../plugin-config.json'), 'utf8') ?? '{}') + proxy.getNavigationTree = fileName => { + if (c('patchOutline') || config.patchOutline) return getNavTreeItems(ts, info, fileName) + return info.languageService.getNavigationTree(fileName) + } } info.languageService[thisPluginMarker] = true diff --git a/typescript/src/ipcTypes.ts b/typescript/src/ipcTypes.ts index 2c30a1c6..bbb166a1 100644 --- a/typescript/src/ipcTypes.ts +++ b/typescript/src/ipcTypes.ts @@ -1,4 +1,4 @@ -export const triggerCharacterCommands = ['find-in-import', 'getPostfixes', 'nodeAtPosition'] as const +export const triggerCharacterCommands = ['find-in-import', 'getPostfixes', 'nodeAtPosition', 'emmet-completions'] as const export type TriggerCharacterCommand = typeof triggerCharacterCommands[number] export type NodeAtPositionResponse = { @@ -7,6 +7,19 @@ export type NodeAtPositionResponse = { end: number } +// export type EmmetResult = { +// label: string +// documentation: any +// insertText: string +// // from cursor position of course +// rangeLength: number +// }[] + +export type EmmetResult = { + /** negative */ + emmetTextOffset: number +} + export type PostfixCompletion = { label: string // replacement: [startOffset: number, endOffset?: number] diff --git a/typescript/src/specialCommands/emmet.ts b/typescript/src/specialCommands/emmet.ts new file mode 100644 index 00000000..1423fe3d --- /dev/null +++ b/typescript/src/specialCommands/emmet.ts @@ -0,0 +1,18 @@ +import { EmmetResult } from '../ipcTypes' +import prepareTextForEmmet from './prepareTextForEmmet' + +export default ( + fileName: string, + nodeLeft: ts.Node, + sourceFile: ts.SourceFile, + position: number, + languageService: ts.LanguageService /* , c: GetConfig */, +): EmmetResult | undefined => { + if (__WEB__) return + const sendToEmmet = prepareTextForEmmet(fileName, nodeLeft, sourceFile, position, languageService) + if (sendToEmmet === false) return + return { + emmetTextOffset: -sendToEmmet.length || 0, + } + // replacementSpan: { start: position - 5, length: 5 }, +} diff --git a/typescript/src/specialCommands/handle.ts b/typescript/src/specialCommands/handle.ts index 2df6b5ce..14b0c1df 100644 --- a/typescript/src/specialCommands/handle.ts +++ b/typescript/src/specialCommands/handle.ts @@ -1,7 +1,7 @@ -import type tslib from 'typescript/lib/tsserverlibrary' import postfixesAtPosition from '../completions/postfixesAtPosition' import { NodeAtPositionResponse, TriggerCharacterCommand, triggerCharacterCommands } from '../ipcTypes' import { findChildContainingPosition } from '../utils' +import getEmmetCompletions from './emmet' export default ( info: ts.server.PluginCreateInfo, @@ -16,8 +16,17 @@ export default ( if (triggerCharacterCommands.includes(specialCommand) && !configuration) { throw new Error('no-ts-essential-plugin-configuration') } + const sourceFile = info.languageService.getProgram()!.getSourceFile(fileName)! + if (specialCommand === 'emmet-completions') { + const leftNode = findChildContainingPosition(ts, sourceFile, position - 1) + if (!leftNode) return + return { + entries: [], + typescriptEssentialsResponse: getEmmetCompletions(fileName, leftNode, sourceFile, position, info.languageService), + } + } if (specialCommand === 'nodeAtPosition') { - const node = findChildContainingPosition(ts, info.languageService.getProgram()!.getSourceFile(fileName)!, position) + const node = findChildContainingPosition(ts, sourceFile, position) return { entries: [], typescriptEssentialsResponse: !node diff --git a/typescript/src/specialCommands/prepareTextForEmmet.ts b/typescript/src/specialCommands/prepareTextForEmmet.ts new file mode 100644 index 00000000..6db67e98 --- /dev/null +++ b/typescript/src/specialCommands/prepareTextForEmmet.ts @@ -0,0 +1,30 @@ +import { findClosestParent } from '../utils' + +const getTextInner = (position: number, leftNode: ts.Node): false | string => { + const { SyntaxKind } = ts + const goodKindsWithText = [SyntaxKind.JsxText] + const justGoodKinds = [SyntaxKind.JsxFragment, SyntaxKind.JsxElement] + const endOnlyKinds = [SyntaxKind.JsxOpeningElement, SyntaxKind.JsxOpeningFragment, SyntaxKind.JsxExpression] + const endPos = position - leftNode.pos + if (goodKindsWithText.includes(leftNode.kind)) return leftNode.getFullText().slice(0, endPos).split(' ').at(-1)! + if (justGoodKinds.includes(leftNode.kind)) return '' + if (endOnlyKinds.includes(leftNode.kind) && leftNode.end === position) return '' + return false +} + +export default ( + fileName: string, + leftNode: ts.Node, + sourceFile: ts.SourceFile, + position: number, + languageService: ts.LanguageService, + // c: GetConfig, +): false | string => { + const text = getTextInner(position, leftNode) + if (text === false) return false + const closestElem = findClosestParent(ts, leftNode, [ts.SyntaxKind.JsxElement], [ts.SyntaxKind.JsxFragment]) as ts.JsxElement | undefined + const bannedTags = ['style'] + const tagName = closestElem?.openingElement.tagName.getText() + if (tagName && bannedTags.includes(tagName)) return false + return text +} diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 9faee399..c316b40e 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -37,7 +37,7 @@ export const getIndentFromPos = (typescript: typeof import('typescript/lib/tsser const { character } = typescript.getLineAndCharacterOfPosition(sourceFile, position) return ( sourceFile - .getText() + .getFullText() .slice(position - character, position) .match(/^\s+/)?.[0] ?? '' ) @@ -55,5 +55,26 @@ export const findClosestParent = (ts: typeof tslib, node: ts.Node, stopKinds: ts export const getLineTextBeforePos = (sourceFile: ts.SourceFile, position: number) => { const { character } = sourceFile.getLineAndCharacterOfPosition(position) - return sourceFile.getText().slice(position - character, position) + return sourceFile.getFullText().slice(position - character, position) +} + +// Workaround esbuild bundle modules +export const nodeModules = __WEB__ + ? null + : { + // emmet: require('@vscode/emmet-helper') as typeof import('@vscode/emmet-helper'), + requireFromString: require('require-from-string'), + fs: require('fs') as typeof import('fs'), + util: require('util') as typeof import('util'), + path: require('path') as typeof import('path'), + } + +/** runtime detection, shouldn't be used */ +export const isWeb = () => { + try { + require('path') + return false + } catch { + return true + } } diff --git a/typescript/test/completions.spec.ts b/typescript/test/completions.spec.ts index 0d71f476..a66c85dc 100644 --- a/typescript/test/completions.spec.ts +++ b/typescript/test/completions.spec.ts @@ -1,3 +1,5 @@ +//@ts-ignore plugin expect it to set globallly +globalThis.__WEB__ = false import { createLanguageService } from '../src/dummyLanguageService' import { getCompletionsAtPosition as getCompletionsAtPositionRaw } from '../src/completionsAtPosition' import type {} from 'vitest/globals' @@ -6,6 +8,8 @@ import { getDefaultConfigFunc } from './defaultSettings' import { isGoodPositionBuiltinMethodCompletion, isGoodPositionMethodCompletion } from '../src/completions/isGoodPositionMethodCompletion' import { getNavTreeItems } from '../src/getPatchedNavTree' import { createRequire } from 'module' +import { findChildContainingPosition } from '../src/utils' +import handleCommand from '../src/specialCommands/handle' const require = createRequire(import.meta.url) //@ts-ignore plugin expect it to set globallly @@ -17,6 +21,7 @@ const files = { [entrypoint]: '' } const { languageService, updateProject } = createLanguageService(files) const getSourceFile = () => languageService.getProgram()!.getSourceFile(entrypoint)! +const getNode = (pos: number) => findChildContainingPosition(ts, getSourceFile(), pos) const newFileContents = (contents: string, fileName = entrypoint) => { const cursorPositions: number[] = [] @@ -31,8 +36,41 @@ const newFileContents = (contents: string, fileName = entrypoint) => { return cursorPositions } +const fileContentsSpecialPositions = (contents: string, fileName = entrypoint) => { + const cursorPositions: [number[], number[], number[]] = [[], [], []] + const cursorPositionsOnly: [number[], number[], number[]] = [[], [], []] + const replacement = /\/\*([tf\d]o?)\*\//g + let currentMatch: RegExpExecArray | null | undefined + while ((currentMatch = replacement.exec(contents))) { + const offset = currentMatch.index + const matchLength = currentMatch[0]!.length + contents = contents.slice(0, offset) + contents.slice(offset + matchLength) + const addOnly = currentMatch[1]!.match(/o$/)?.[0] + const addArr = addOnly ? cursorPositionsOnly : cursorPositions + let mainMatch = currentMatch[1]! + if (addOnly) mainMatch = mainMatch.slice(0, -1) + const possiblyNum = +mainMatch + if (!isNaN(possiblyNum)) { + addArr[2][possiblyNum] = offset + } else { + addArr[mainMatch === 't' ? '0' : '1'].push(offset) + } + replacement.lastIndex -= matchLength + } + files[fileName] = contents + updateProject() + if (cursorPositionsOnly.some(arr => arr.length)) { + if (process.env.CI) throw new Error('Only positions not allowed on CI') + return cursorPositionsOnly + } + return cursorPositions +} + +const settingsOverride = { + 'arrayMethodsSnippets.enable': true, +} //@ts-ignore -const defaultConfigFunc = await getDefaultConfigFunc() +const defaultConfigFunc = await getDefaultConfigFunc(settingsOverride) const getCompletionsAtPosition = (pos: number, fileName = entrypoint) => { if (pos === undefined) throw new Error('getCompletionsAtPosition: pos is undefined') @@ -58,7 +96,7 @@ test('Banned positions', () => { expect(getCompletionsAtPosition(cursorPositions[2]!)?.entries).toHaveLength(1) }) -test('Builtin method snippet banned positions', () => { +test('Banned positions for all method snippets', () => { const cursorPositions = newFileContents(/* tsx */ ` import {/*|*/} from 'test' const obj = { m$1e$2thod() {}, arrow: () => {} } @@ -66,25 +104,6 @@ test('Builtin method snippet banned positions', () => { const test = () => ({ method() {} }) const {/*|*/} = test() const {something, met/*|*/} = test() - ; - ; - ; - ; - ; - ; - ; - `) - for (const [i, pos] of cursorPositions.entries()) { - const result = isGoodPositionBuiltinMethodCompletion(ts, getSourceFile(), pos, defaultConfigFunc) - expect(result, i.toString()).toBeFalsy() - } - const insertTextEscaping = getCompletionsAtPosition(cursorPositions[1]!)!.entries[1]?.insertText! - expect(insertTextEscaping).toEqual('m\\$1e\\$2thod') -}) - -test('Additional banned positions for our method snippets', () => { - const cursorPositions = newFileContents(/* tsx */ ` - const test = () => ({ method() {} }) test({ method/*|*/ }) @@ -100,12 +119,14 @@ test('Additional banned positions for our method snippets', () => { ; `) for (const [i, pos] of cursorPositions.entries()) { - const result = isGoodPositionMethodCompletion(ts, entrypoint, getSourceFile(), pos - 1, languageService, defaultConfigFunc) + const result = isGoodPositionBuiltinMethodCompletion(ts, getSourceFile(), pos, defaultConfigFunc) expect(result, i.toString()).toBeFalsy() } + const insertTextEscaping = getCompletionsAtPosition(cursorPositions[1]!)!.entries[1]?.insertText! + expect(insertTextEscaping).toEqual('m\\$1e\\$2thod') }) -test('Not banned positions for our method snippets', () => { +test('Not banned positions for method snippets', () => { const cursorPositions = newFileContents(/* ts */ ` const test = () => ({ method() {} }) const test2 = () => {} @@ -139,6 +160,66 @@ test('Function props: cleans & highlights', () => { expect(entryNamesHighlighted).includes('☆sync') }) +test('Emmet completion', () => { + const [positivePositions, negativePositions, numPositions] = fileContentsSpecialPositions(/* tsx */ ` + // is it readable enough? + ;
.test/*2*/
+ const a =
/*t*/ /*t*/test/*0*/ + /*t*/{}/*t*/ good ul>li/*1*/ + /*t*/
} >/*t*/; + const a =
/*t*/ + /*t*/
; + + const a =
/*t*/
+ const a =
/*t*/
+ const a = /*t*/ + const a = <>/*t*/ + const a = /*t*/ + + // https://github.com/microsoft/vscode/issues/119736 + + ; + : + `) + const numPositionsTextLength = { + 0: -4, + 1: -5, + 2: -5, + } + const getEmmetCompletions = pos => { + const result = handleCommand({ languageService } as any, entrypoint, pos, 'emmet-completions', defaultConfigFunc) + return result?.typescriptEssentialsResponse?.emmetTextOffset + } + for (const [i, pos] of positivePositions.entries()) { + expect(getEmmetCompletions(pos), i.toString()).toBe(0) + } + for (const [i, pos] of Object.entries(numPositions)) { + expect(getEmmetCompletions(pos), i.toString()).toBe(numPositionsTextLength[i]) + } + for (const [i, pos] of negativePositions.entries()) { + expect(getEmmetCompletions(pos), i.toString()).toBeUndefined() + } +}) + +test('Array Method Snippets', () => { + const positions = newFileContents(/*ts*/ ` + const users = [] + users./*|*/ + ;users.filter(Boolean).flatMap/*|*/ + `) + for (const [i, pos] of positions.entries()) { + const { entries } = getCompletionsAtPosition(pos) ?? {} + expect(entries?.find(({ name }) => name === 'flatMap')?.insertText, i.toString()).toBe('flatMap((${2:user}) => $3)') + } +}) + +// TODO move/remove this test from here test('Patched navtree (outline)', () => { globalThis.__TS_SEVER_PATH__ = require.resolve('typescript/lib/tsserver') newFileContents(/* tsx */ ` diff --git a/typescript/test/defaultSettings.ts b/typescript/test/defaultSettings.ts index 2008aa88..a03ed5f2 100644 --- a/typescript/test/defaultSettings.ts +++ b/typescript/test/defaultSettings.ts @@ -1,6 +1,7 @@ import { readPackageJsonFile } from 'typed-jsonfile' +import { Configuration } from '../src/types' -export const getDefaultConfig = async () => { +export const getDefaultConfig = async (skipKeys: string[]) => { let configProps try { configProps = ((await readPackageJsonFile('./out/package.json')) as any).contributes.configuration.properties @@ -8,15 +9,18 @@ export const getDefaultConfig = async () => { throw new Error('Run vscode-framework build before running tests!') } return Object.fromEntries( - Object.entries(configProps as Record).map(([setting, { default: defaultValue }]) => { - const settingWithoutPrefix = setting.split('.').slice(1).join('.') - if (defaultValue === undefined) throw new Error(`${settingWithoutPrefix} doesn't have default value!`) - return [settingWithoutPrefix, defaultValue] - }), + Object.entries(configProps as Record) + .map(([setting, { default: defaultValue }]) => { + const settingWithoutPrefix = setting.split('.').slice(1).join('.') + if (skipKeys.includes(settingWithoutPrefix)) return undefined! + if (defaultValue === undefined) throw new Error(`${settingWithoutPrefix} doesn't have default value!`) + return [settingWithoutPrefix, defaultValue] + }) + .filter(Boolean), ) } -export const getDefaultConfigFunc = async () => { - const defaultConfig = await getDefaultConfig() - return (config: string) => defaultConfig[config] +export const getDefaultConfigFunc = async (settingsOverrides: Partial = {}) => { + const defaultConfig = await getDefaultConfig(Object.keys(settingsOverrides)) + return (setting: string) => settingsOverrides[setting] ?? defaultConfig[setting] } diff --git a/vscode-framework.config.js b/vscode-framework.config.js index 7b966fb4..52b34a66 100644 --- a/vscode-framework.config.js +++ b/vscode-framework.config.js @@ -4,13 +4,19 @@ const { patchPackageJson } = require('@zardoy/vscode-utils/build/patchPackageJso patchPackageJson({ patchSettings(configuration) { + //prettier-ignore configuration['jsxPseudoEmmet.tags'].default = { div: true, span: true, input: "", p: true, form: true, footer: true, section: true, select: true, h1: true, h2: true, h3: true, h4: true, h5: true, h6: true, } return configuration - } + }, }) module.exports = defineConfig({ + consoleStatements: process.argv.includes('--web') ? false : undefined, development: {}, + target: { + web: true, + desktop: true, + }, })