coc-vscode-loader 1.1.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,40 +2,70 @@ import { Project } from 'ts-morph'
2
2
  import * as fs from 'fs'
3
3
  import * as path from 'path'
4
4
  import { scan } from './scanner.js'
5
- import { TransformContext } from './types.js'
6
- import { transformImportMapping } from './transforms/import-mapping.js'
7
- import { transformLanguageClient } from './transforms/language-client.js'
8
- import { transformClassToFactory } from './transforms/class-to-factory.js'
9
- import { transformProviderRegister } from './transforms/provider-register.js'
10
- import { transformEnumOffset } from './transforms/enum-offset.js'
11
- import { getActivePresets, generateBridgeCode } from './presets.js'
12
-
13
- const TRANSFORMS = [
14
- { name: 'import-mapping', fn: transformImportMapping },
15
- { name: 'class-to-factory', fn: transformClassToFactory },
16
- { name: 'provider-register', fn: transformProviderRegister },
17
- { name: 'enum-offset', fn: transformEnumOffset },
18
- { name: 'language-client', fn: transformLanguageClient },
19
- ]
20
-
21
- interface Options {
5
+ import {
6
+ ConvertStep,
7
+ isLanguageClientStep,
8
+ StepResult,
9
+ } from './types.js'
10
+ import { executeStep, getRegisteredStepTypes } from './steps/index.js'
11
+
12
+ export interface ConvertOptions {
22
13
  input: string
23
14
  output: string
24
- bridge?: boolean
15
+ convert: ConvertStep[]
16
+ presets?: Record<string, any>
25
17
  verbose?: boolean
26
18
  }
27
19
 
28
- export async function convert(opts: Options): Promise<void> {
29
- const { input, output } = opts
20
+ interface MergedResult {
21
+ generatedFiles: Array<{ path: string; content: string }>
22
+ entryPoint?: string
23
+ keepDeps: Record<string, string>
24
+ activationEvents: string[]
25
+ serverBinary?: {
26
+ repo: string
27
+ asset: string
28
+ binaryPath?: string
29
+ args?: string[]
30
+ }
31
+ }
32
+
33
+ export async function convert(opts: ConvertOptions): Promise<void> {
34
+ const { input, output, convert: steps, verbose } = opts
30
35
 
31
36
  if (!fs.existsSync(input)) {
32
37
  console.error(`input not found: ${input}`)
33
38
  process.exit(1)
34
39
  }
35
40
 
36
- // 1. Scan
41
+ if (!steps || steps.length === 0) {
42
+ console.error('no convert steps provided (use --convert)')
43
+ process.exit(1)
44
+ }
45
+
46
+ // 1. Validate
47
+ const knownTypes = getRegisteredStepTypes()
48
+ for (const s of steps) {
49
+ if (!knownTypes.includes(s.type)) {
50
+ console.error(`unknown step type: "${s.type}". Available: ${knownTypes.join(', ')}`)
51
+ process.exit(1)
52
+ }
53
+ if (isLanguageClientStep(s) && s.server.kind === 'binary') {
54
+ if (s.server.binary?.asset) {
55
+ const vars = ['{{version}}', '{{platform}}', '{{arch}}', '{{rust-target}}']
56
+ for (const v of vars) {
57
+ if (s.server.binary.asset.includes(v)) break
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // 2. Scan for summary (try src/ first, fall back to input root)
37
64
  console.log('Scanning...')
38
- const result = scan(input)
65
+ let result = scan(path.join(input, 'src'))
66
+ if (result.files.length === 0) {
67
+ result = scan(input)
68
+ }
39
69
  console.log(result.summary)
40
70
 
41
71
  if (result.files.length === 0) {
@@ -43,285 +73,222 @@ export async function convert(opts: Options): Promise<void> {
43
73
  return
44
74
  }
45
75
 
46
- // 2. Create output directory
47
- if (fs.existsSync(output)) {
48
- fs.rmSync(output, { recursive: true })
49
- }
50
- fs.mkdirSync(path.join(output, 'src'), { recursive: true })
51
-
52
76
  // 3. Read original package.json
53
77
  const origPkgPath = path.join(input, 'package.json')
54
78
  const origPkg = fs.existsSync(origPkgPath)
55
79
  ? JSON.parse(fs.readFileSync(origPkgPath, 'utf-8'))
56
80
  : {}
57
81
 
58
- // 4. Copy source files (only .ts that have vscode usage)
59
- const srcDir = path.join(input, 'src')
60
- const copiedFiles: string[] = []
61
- if (fs.existsSync(srcDir)) {
62
- for (const file of fs.readdirSync(srcDir)) {
63
- if (!file.endsWith('.ts')) continue
64
- const srcPath = path.join(srcDir, file)
65
- const destPath = path.join(output, 'src', file)
66
- fs.copyFileSync(srcPath, destPath)
67
- copiedFiles.push(file)
68
- }
82
+ // 4. Create output directory
83
+ if (fs.existsSync(output)) {
84
+ fs.rmSync(output, { recursive: true })
69
85
  }
86
+ fs.mkdirSync(path.join(output, 'src'), { recursive: true })
70
87
 
71
- // 5. Load files with ts-morph and apply transforms
88
+ // 5. Project for transforms
72
89
  const project = new Project()
73
- const srcFiles = copiedFiles
74
- .map(f => path.join(output, 'src', f))
75
- .filter(f => fs.existsSync(f))
76
90
 
77
- for (const fp of srcFiles) {
78
- try { project.addSourceFileAtPath(fp) } catch {}
91
+ // 6. Execute steps
92
+ const ctx = { input, output, project, origPkg, verbose, presets: opts.presets }
93
+ const merged: MergedResult = {
94
+ generatedFiles: [],
95
+ entryPoint: undefined,
96
+ keepDeps: {},
97
+ activationEvents: [],
98
+ serverBinary: undefined,
79
99
  }
80
100
 
81
- for (const file of project.getSourceFiles()) {
82
- const ctx: TransformContext = { file, project }
83
- for (const t of TRANSFORMS) {
84
- try {
85
- t.fn(ctx)
86
- if (opts.verbose) console.log(` ${t.name}: ${path.basename(file.getFilePath())}`)
87
- } catch (e: any) {
88
- console.warn(` ${t.name} error: ${e.message}`)
89
- }
101
+ const stepResults: StepResult[] = []
102
+ for (const step of steps) {
103
+ const stepVerbose = verbose || (step as any).verbose
104
+ if (stepVerbose) console.log(` step: ${step.type}`)
105
+ const stepCtx = { ...ctx, verbose: stepVerbose }
106
+ const stepResult: StepResult = executeStep(stepCtx, step)
107
+ stepResults.push(stepResult)
108
+
109
+ // Merge
110
+ merged.generatedFiles.push(...stepResult.generatedFiles)
111
+ merged.entryPoint = stepResult.entryPoint || merged.entryPoint
112
+ Object.assign(merged.keepDeps, stepResult.keepDeps)
113
+ merged.activationEvents.push(...stepResult.activationEvents)
114
+ if (stepResult.serverBinary) {
115
+ merged.serverBinary = stepResult.serverBinary
90
116
  }
91
- file.saveSync()
92
117
  }
93
118
 
94
- // 6. Mark unsupported code patterns in source files
95
- for (const file of result.files) {
96
- if (!file.path.startsWith('src/') || !file.path.endsWith('.ts')) continue
97
- const fp = path.join(output, file.path)
98
- if (!fs.existsSync(fp)) continue
99
-
100
- let content = fs.readFileSync(fp, 'utf-8')
101
-
102
- // Mark unsupported lines
103
- for (const { pattern, label } of [
104
- { pattern: 'createTextEditorDecorationType', label: 'decoration API' },
105
- { pattern: 'createWebviewPanel', label: 'webview API' },
106
- { pattern: 'registerTreeDataProvider', label: 'tree data provider' },
107
- { pattern: 'env.openExternal', label: 'env.openExternal' },
108
- ]) {
109
- content = content.replace(
110
- new RegExp(`^(.*${pattern}.*)$`, 'gm'),
111
- '// [converter] TODO: $1 — coc has no equivalent for ' + label
112
- )
113
- }
119
+ // 8. Apply code injections from steps (generic, no hardcoded plugin knowledge)
120
+ for (const stepResult of stepResults) {
121
+ if (!stepResult.codeInjections) continue
122
+ for (const inj of stepResult.codeInjections) {
123
+ const targetFile = merged.generatedFiles.find(f => f.path === inj.target)
124
+ if (!targetFile) continue
125
+
126
+ let content = targetFile.content
127
+
128
+ // Add import codes
129
+ if (inj.importCode) {
130
+ if (inj.importCode.endsWith(',')) {
131
+ // Import addition (adds a line to existing import block)
132
+ const marker = inj.insertBefore || "} from 'coc.nvim'"
133
+ if (!content.includes(inj.importCode.trim())) {
134
+ content = content.replace(marker, `${inj.importCode}\n${marker}`)
135
+ }
136
+ } else {
137
+ // Full import line
138
+ if (!content.includes(inj.importCode)) {
139
+ content = inj.importCode + '\n' + content
140
+ }
141
+ }
142
+ }
114
143
 
115
- // Replace getWordRangeAtPosition calls with inline word boundary calculation.
116
- // In coc's TextDocument there's no getWordRangeAtPosition.
117
- content = content.replace(
118
- /const range = document\.getWordRangeAtPosition\(([^,]+),\s*([^)]+)\)/g,
119
- 'const line = document.getText().split("\\n")[$1.line]; const pre = line.slice(0, $1.character); const m = pre.match($2); const range = m ? Range.create($1.line, $1.character - m[0].length, $1.line, $1.character) : undefined'
120
- )
121
- content = content.replace(
122
- /const range = document\.getWordRangeAtPosition\(([^)]+)\)/g,
123
- 'const line = document.getText().split("\\n")[$1.line]; const pre = line.slice(0, $1.character); const m = pre.match(/[_a-zA-Z0-9-]+/); const range = m ? Range.create($1.line, $1.character - m[0].length, $1.line, $1.character) : undefined'
124
- )
125
- // Also replace usage without 'const' (already declared)
126
- content = content.replace(
127
- /range = document\.getWordRangeAtPosition\(([^,]+),\s*([^)]+)\)/g,
128
- '(() => { const ln = document.getText().split("\\n")[$1.line]; const pr = ln.slice(0, $1.character); const mt = pr.match($2); return mt ? Range.create($1.line, $1.character - mt[0].length, $1.line, $1.character) : undefined; })()'
129
- )
130
- // fileName → uri (coc's DocumentUri is a string path)
131
- if (content.includes('.fileName')) {
132
- content = content.replace(/\.fileName/g, '.uri')
133
- // uri is a full path in coc, but fileName might have had file:// prefix in vscode
134
- // Add comment about potential file:// handling
135
- }
144
+ // Insert code before a marker
145
+ if (inj.insertBefore && inj.code) {
146
+ if (!content.includes(inj.code.trim())) {
147
+ content = content.replace(inj.insertBefore, inj.code + inj.insertBefore)
148
+ }
149
+ }
136
150
 
137
- // Remove unsupported import packages
138
- content = content.replace(
139
- /import .* from ['"]@volar\/vscode['"];?\n?/g,
140
- ''
141
- )
142
- content = content.replace(
143
- /import .* from ['"]reactive-vscode['"];?\n?/g,
144
- ''
145
- )
146
- content = content.replace(
147
- /import \* as lsp from ['"]@volar\/vscode\/node['"];?\n?/g,
148
- ''
149
- )
150
-
151
- fs.writeFileSync(fp, content)
152
- }
151
+ // Insert code after a marker
152
+ if (inj.insertAfter && inj.code) {
153
+ if (!content.includes(inj.code.trim())) {
154
+ content = content.replace(inj.insertAfter, inj.insertAfter + inj.code)
155
+ }
156
+ }
153
157
 
154
- // 7. Detect config namespace from original properties
155
- const origProps = origPkg.contributes?.configuration?.properties || {}
156
- const configNamespace = Object.keys(origProps).length > 0
157
- ? [...new Set(Object.keys(origProps).map(k => k.split('.')[0]))][0]
158
- : origPkg.name || path.basename(input)
158
+ targetFile.content = content
159
+ if (verbose) console.log(` injected into ${inj.target}: ${(inj.importCode || inj.code).substring(0, 60)}...`)
160
+ }
161
+ }
159
162
 
160
- // 8. Detect server module from original source
161
- const serverModuleNames = detectServerModules(input, result)
162
- if (serverModuleNames.length > 0) {
163
- console.log(` detected server: ${serverModuleNames.join(', ')}`)
163
+ // 9. Write generated files
164
+ for (const gf of merged.generatedFiles) {
165
+ const fp = path.join(output, gf.path)
166
+ fs.mkdirSync(path.dirname(fp), { recursive: true })
167
+ fs.writeFileSync(fp, gf.content)
168
+ if (verbose) console.log(` wrote: ${gf.path}`)
164
169
  }
165
170
 
166
- // 9. Determine plugin type and entry point
167
- const hasTsBridge = result.hasTsBridge
168
- const hasServer = serverModuleNames.length > 0
169
- const isDirectApi = !hasServer && !hasTsBridge
170
- const pluginName = origPkg.name || path.basename(input)
171
- const description = origPkg.description || ''
172
- const activePresets = getActivePresets(hasTsBridge, result)
173
-
174
- // Build server resolution code
175
- const serverResolveCalls = serverModuleNames.map(name => {
176
- return `\
177
- try { serverModule = require.resolve('${escapeStr(name)}') } catch {}
178
- try {
179
- const _mainPath = require.resolve('${escapeStr(name)}/package.json');
180
- let _dir = require('path').dirname(_mainPath);
181
- while (_dir !== require('path').dirname(_dir)) {
182
- const _pkgPath = require('path').join(_dir, 'package.json');
183
- if (require('fs').existsSync(_pkgPath)) {
184
- const _pkg = JSON.parse(require('fs').readFileSync(_pkgPath, 'utf-8'));
185
- if (_pkg.bin) {
186
- const _entry = typeof _pkg.bin === 'string' ? _pkg.bin : Object.values(_pkg.bin)[0];
187
- serverModule = require('path').join(_dir, _entry);
188
- }
189
- break;
171
+ // 10. Apply text replacements to all source files (getWordRangeAtPosition, .fileName)
172
+ {
173
+ const outputSrc = path.join(output, 'src')
174
+ if (fs.existsSync(outputSrc)) {
175
+ for (const f of fs.readdirSync(outputSrc, { recursive: true })) {
176
+ const fp = typeof f === 'string' ? path.join(outputSrc, f) : f
177
+ if (!fp.endsWith('.ts')) continue
178
+ let content = fs.readFileSync(fp, 'utf-8')
179
+ let changed = false
180
+
181
+ if (content.includes('document.getWordRangeAtPosition')) {
182
+ // const range = document.getWordRangeAtPosition(pos, regex)
183
+ content = content.replace(
184
+ /const range = document\.getWordRangeAtPosition\(([^,]+),\s*([^)]+)\)/g,
185
+ 'const line = document.getText().split("\\n")[$1.line]; const pre = line.slice(0, $1.character); const m = pre.match($2); const range = m ? { start: { line: $1.line, character: $1.character - m[0].length }, end: { line: $1.line, character: $1.character } } : undefined'
186
+ )
187
+ // const range = document.getWordRangeAtPosition(pos)
188
+ content = content.replace(
189
+ /const range = document\.getWordRangeAtPosition\(([^)]+)\)/g,
190
+ 'const line = document.getText().split("\\n")[$1.line]; const pre = line.slice(0, $1.character); const m = pre.match(/[_a-zA-Z0-9-]+/); const range = m ? { start: { line: $1.line, character: $1.character - m[0].length }, end: { line: $1.line, character: $1.character } } : undefined'
191
+ )
192
+ // range = document.getWordRangeAtPosition(pos, regex) (without const)
193
+ content = content.replace(
194
+ /range = document\.getWordRangeAtPosition\(([^,]+),\s*([^)]+)\)/g,
195
+ '(() => { const ln = document.getText().split("\\n")[$1.line]; const pr = ln.slice(0, $1.character); const mt = pr.match($2); return mt ? { start: { line: $1.line, character: $1.character - mt[0].length }, end: { line: $1.line, character: $1.character } } : undefined; })()'
196
+ )
197
+ changed = true
190
198
  }
191
- _dir = require('path').dirname(_dir);
192
- }
193
- } catch {}`
194
- }).join('\n')
195
-
196
- // Generate bridge code from presets
197
- const bridgeCode = generateBridgeCode(activePresets)
198
- const needsExtensions = activePresets.some(p => p.name === 'ts-bridge')
199
- const needsCommands = activePresets.some(p => p.requiresCommand)
200
199
 
201
- let fullCode = ''
202
- let esbuildEntry = 'src/index.ts'
203
- if (isDirectApi) {
204
- esbuildEntry = 'src/extension.ts'
205
- } else {
206
- fullCode = `\
207
- import {
208
- LanguageClient,
209
- TransportKind,
210
- workspace,
211
- window,
212
- commands,
213
- services as cocServices,
214
- ExtensionContext${needsExtensions ? ',\n extensions' : ''},
215
- } from 'coc.nvim'
216
- import * as path from 'path'
217
- import * as fs from 'fs'
200
+ if (content.includes('.fileName') || content.includes('.uri.fsPath')) {
201
+ // .fileName .uri (coc's TextDocument#uri returns a string path, not a URI object)
202
+ content = content.replace(/(document|this\.document|textDocument|scope)\.fileName/g, '$1.uri')
203
+ // .uri.fsPath .uri (coc's uri is already a string path, not a URI object)
204
+ content = content.replace(/\.uri\.fsPath/g, '.uri')
205
+ changed = true
206
+ }
218
207
 
219
- export async function activate(context: ExtensionContext): Promise<void> {
220
- try {
221
- ${needsExtensions ? `\
222
- const tsExt = extensions.all.find(e => e.id === 'coc-tsserver')
223
- if (tsExt && !tsExt.isActive) { await tsExt.activate() }
224
- const tsSvc = cocServices.getService('tsserver')
225
- if (tsSvc) { await tsSvc.start() }
226
- ` : ''}\
227
- const config = workspace.getConfiguration('${escapeStr(configNamespace)}')
228
- let serverModule = config.get<string>('server.path', '')
229
- if (serverModule) {
230
- serverModule = path.isAbsolute(serverModule) ? serverModule : path.join(workspace.root, serverModule)
231
- }
232
- if (!serverModule || !fs.existsSync(serverModule)) {
233
- ${serverResolveCalls}
208
+ if (changed) fs.writeFileSync(fp, content)
209
+ }
234
210
  }
235
- if (!serverModule) { window.showErrorMessage('Cannot find language server.'); return }
236
-
237
- const client = new LanguageClient(
238
- '${escapeStr(pluginName)}',
239
- '${escapeStr(description || pluginName)}',
240
- { module: serverModule, transport: TransportKind.ipc },
241
- {
242
- documentSelector: [{ language: '${escapeStr(configNamespace)}', scheme: 'file' }],
243
- outputChannelName: '${escapeStr(description || pluginName)}',
244
- },
245
- )
246
- context.subscriptions.push({ dispose: () => client.stop() })
247
- context.subscriptions.push(cocServices.registerLanguageClient(client))
248
- client.start()
249
-
250
- ${bridgeCode ? bridgeCode + '\n\n' : ''}\
251
- // Restart command
252
- context.subscriptions.push(
253
- commands.registerCommand('${escapeStr(pluginName)}.restartServer', async () => {
254
- await client.stop()
255
- client.start()
256
- }),
257
- )
258
- } catch (e: any) {
259
- window.showErrorMessage('${escapeStr(pluginName)} error: ' + (e.message || String(e)))
260
- }
261
- }
262
- `
263
211
  }
264
- if (fullCode) {
265
- fs.writeFileSync(path.join(output, 'src', 'index.ts'), fullCode)
212
+
213
+ // 12. Determine entry point
214
+ const hasLanguageClient = steps.some(s => s.type === 'language-client')
215
+
216
+ // If language-client step exists, it generates src/index.ts as entry.
217
+ // If source step provides an entry point, the generated index.ts imports it.
218
+ // For source-only plugins, entry is the source entry.
219
+ let esbuildEntry = 'src/index.ts'
220
+ if (!hasLanguageClient) {
221
+ esbuildEntry = merged.entryPoint
222
+ ? `src/${merged.entryPoint.replace(/^src\//, '')}`
223
+ : 'src/extension.ts'
266
224
  }
267
225
 
268
- // 10. Detect dependencies from original package.json + preset extras
226
+ // 13. Generate package.json
227
+ const pluginName = origPkg.name || path.basename(input)
228
+
229
+ // Detect config namespace (from original contributes.properties or name)
230
+ const origProps = origPkg.contributes?.configuration?.properties || {}
231
+ const configNamespace = Object.keys(origProps).length > 0
232
+ ? [...new Set(Object.keys(origProps).map((k: string) => k.split('.')[0]))][0]
233
+ : pluginName
234
+
235
+ // For bridge mode: detect TypeScript plugins from source + origPkg
236
+ const hasBridge = steps.some(s => s.type === 'bridge')
237
+ const tsPlugins = hasBridge ? scanTypeScriptPlugins(input, origPkg) : []
238
+ const description = origPkg.description || ''
239
+
240
+ const activationEvents = merged.activationEvents.length > 0
241
+ ? merged.activationEvents
242
+ : ['onLanguage']
243
+
244
+ // Gather server deps: for module-kind language-client steps
269
245
  const serverDeps: Record<string, string> = {}
270
- // Add preset extra deps (e.g. typescript for ts-bridge)
271
- for (const preset of activePresets) {
272
- for (const dep of (preset.extraDeps || [])) {
273
- if (!serverDeps[dep]) serverDeps[dep] = '*'
274
- }
275
- }
276
- if (isDirectApi) {
277
- // Non-LSP: keep all original deps (including devDeps, since they may be imported at runtime)
278
- const allDeps = { ...origPkg.dependencies, ...origPkg.devDependencies }
279
- for (const [dep, ver] of Object.entries(allDeps)) {
280
- if (ver.startsWith('workspace:')) continue
281
- if (dep.startsWith('@types/')) continue // skip type packages
282
- if (['typescript', 'mocha', 'c8', 'prettier', 'rollup', '@vscode/'].some(p => dep.startsWith(p))) continue
283
- if (['tslib'].includes(dep)) continue
284
- serverDeps[dep] = ver as string
285
- }
286
- } else {
287
- // LSP-based: only include server-related dependencies
288
- const knownPatterns = ['language-server', 'language-server/node', '-server', 'languageserver', '/lsp']
289
- for (const [dep, ver] of Object.entries(origPkg.dependencies || {})) {
290
- if (ver.startsWith('workspace:')) {
291
- // Workspace deps: if it matches a known server pattern, add with wildcard version
292
- if (knownPatterns.some(p => dep.includes(p))) {
293
- serverDeps[dep] = '*'
294
- }
295
- continue
296
- }
297
- if (knownPatterns.some(p => dep.includes(p))) {
298
- serverDeps[dep] = ver as string
246
+ for (const s of steps) {
247
+ if (isLanguageClientStep(s) && s.server.kind === 'module') {
248
+ const pkg = s.server.package
249
+ const ver = origPkg.dependencies?.[pkg] || origPkg.devDependencies?.[pkg]
250
+ if (ver && ver.startsWith('workspace:')) {
251
+ serverDeps[pkg] = '*' // monorepo workspace → wildcard for npm
252
+ } else {
253
+ serverDeps[pkg] = ver || '*'
299
254
  }
300
255
  }
301
- for (const name of serverModuleNames) {
302
- const pkgName = name.startsWith('@') ? name.split('/').slice(0, 2).join('/') : name.split('/')[0]
303
- if (!serverDeps[pkgName]) serverDeps[pkgName] = '*'
256
+ }
257
+
258
+ // Collect all runtime deps from origPkg (deps + devDeps, filtered)
259
+ const origDeps: Record<string, string> = {}
260
+ {
261
+ const allPkgDeps = { ...origPkg.dependencies, ...origPkg.devDependencies }
262
+ for (const [dep, ver] of Object.entries(allPkgDeps)) {
263
+ const v = ver as string
264
+ if (v.startsWith('workspace:')) continue
265
+ if (dep.startsWith('@types/')) continue
266
+ if (['typescript', 'mocha', 'c8', 'prettier', 'rollup', 'esbuild', '@vscode/'].some(p => dep.startsWith(p))) continue
267
+ origDeps[dep] = v
304
268
  }
305
269
  }
306
- const activationEvents = Array.isArray(origPkg.activationEvents) ? origPkg.activationEvents : []
307
- const activationEvent = activationEvents.find((e: string) => e.startsWith('onLanguage:'))
308
- || (activationEvents.includes('*') ? '*' : undefined)
309
- || (activationEvents.includes('onStartupFinished') ? '*' : undefined)
310
- || `onLanguage:${configNamespace}`
311
- || 'onLanguage'
270
+
271
+ const deps: Record<string, string> = {
272
+ ...serverDeps,
273
+ ...origDeps,
274
+ ...merged.keepDeps,
275
+ }
276
+ // Fix workspace: protocol deps (monorepo, invalid in npm) → wildcard
277
+ for (const k of Object.keys(deps)) {
278
+ if (typeof deps[k] === 'string' && deps[k].startsWith('workspace:')) deps[k] = '*'
279
+ }
280
+
312
281
  const pkg = {
313
282
  name: pluginName.startsWith('coc-') ? pluginName : `coc-${pluginName}`,
314
283
  version: origPkg.version || '0.1.0',
315
- dependencies: {
316
- ...serverDeps,
317
- },
284
+ description,
285
+ main: 'lib/index.js',
286
+ engines: { coc: '^0.0.82' },
287
+ activationEvents,
288
+ dependencies: deps,
318
289
  devDependencies: {
319
290
  esbuild: '^0.28.0',
320
291
  },
321
- description: description,
322
- main: 'lib/index.js',
323
- engines: { coc: '^0.0.82' },
324
- activationEvents: [activationEvent],
325
292
  contributes: {
326
293
  configuration: origPkg.contributes?.configuration ? {
327
294
  type: 'object',
@@ -332,39 +299,42 @@ ${bridgeCode ? bridgeCode + '\n\n' : ''}\
332
299
  command: c.command,
333
300
  title: c.title,
334
301
  })) || undefined,
335
- ...(hasTsBridge ? {
336
- typescriptServerPlugins: (origPkg.contributes?.typescriptServerPlugins?.length
337
- ? origPkg.contributes?.typescriptServerPlugins
338
- : scanTypeScriptPlugins(input, result).map(p => ({
339
- ...p,
340
- languages: p.languages.length ? p.languages : [configNamespace],
341
- }))
342
- ) || [],
302
+ ...(tsPlugins.length > 0 ? {
303
+ typescriptServerPlugins: tsPlugins.map(p => ({
304
+ ...p,
305
+ languages: p.languages.length ? p.languages : [configNamespace],
306
+ })),
343
307
  } : {}),
344
308
  },
345
309
  }
346
310
 
347
311
  // Clean null fields
348
- for (const key of ['configuration', 'commands'] as const) {
349
- if (!pkg.contributes[key]) delete pkg.contributes[key]
350
- }
351
- if (Object.keys(pkg.contributes).length === 0) delete pkg.contributes
312
+ if (!pkg.contributes?.configuration) delete (pkg.contributes as any).configuration
313
+ if (!pkg.contributes?.commands) delete (pkg.contributes as any).commands
314
+ if (Object.keys(pkg.contributes).length === 0) delete (pkg as any).contributes
352
315
 
353
316
  fs.writeFileSync(path.join(output, 'package.json'), JSON.stringify(pkg, null, 2))
354
317
 
355
- // 10. Generate esbuild config
356
- const externalMods = ['coc.nvim', ...serverModuleNames]
357
- .map(n => n.startsWith('@') ? n.split('/').slice(0, 2).join('/') : n.split('/')[0])
358
- .filter((v, i, a) => v && a.indexOf(v) === i)
318
+ // 14. Generate esbuild config — externalize all runtime deps
319
+ const externalMods = [
320
+ 'coc.nvim',
321
+ ...Object.keys(serverDeps),
322
+ ...Object.keys(merged.keepDeps),
323
+ ...Object.keys(origDeps),
324
+ ].flatMap(n => {
325
+ if (n.startsWith('@')) return n.split('/').slice(0, 2).join('/')
326
+ return n.split('/')[0]
327
+ }).filter((v, i, a) => v && a.indexOf(v) === i)
328
+
359
329
  const esbuildConfig = `\
360
330
  import * as esbuild from 'esbuild'
361
331
 
362
332
  const options = {
363
- entryPoints: ['${escapeStr(esbuildEntry)}'],
333
+ entryPoints: ['${esbuildEntry}'],
364
334
  bundle: true,
365
335
  minify: false,
366
336
  mainFields: ['module', 'main'],
367
- external: [${externalMods.map(m => `'${escapeStr(m)}'`).join(', ')}],
337
+ external: [${externalMods.map(m => `'${m}'`).join(', ')}],
368
338
  platform: 'node',
369
339
  target: 'node18',
370
340
  outfile: 'lib/index.js',
@@ -378,35 +348,40 @@ if (result.errors.length) {
378
348
  `
379
349
  fs.writeFileSync(path.join(output, 'esbuild.mjs'), esbuildConfig)
380
350
 
381
- // 11. Generate report
351
+ // 15. Write step metadata for pipeline
352
+ const meta = {
353
+ entryPoint: esbuildEntry,
354
+ activationEvents,
355
+ keepDeps: merged.keepDeps,
356
+ serverDeps,
357
+ serverBinary: merged.serverBinary,
358
+ hasLanguageClient,
359
+ }
360
+ fs.writeFileSync(path.join(output, 'coc-convert.json'), JSON.stringify(meta, null, 2))
361
+
362
+ // 16. Print report
382
363
  console.log('\n=== Conversion Report ===')
383
364
  console.log(` Plugin: ${pkg.name}`)
384
- console.log(` Type: ${hasTsBridge ? 'ts-bridge' : 'pure-lsp'}`)
385
- console.log(` Source files: ${copiedFiles.length + (hasTsBridge ? 0 : 0)}`)
386
-
387
- if (hasTsBridge) {
388
- console.log('\n TS-bridge mode:')
389
- console.log(' - Generated src/index.ts with tsserver/request bridge')
390
- console.log(' - Package.json includes typescriptServerPlugins')
391
- console.log(' - Requires modified coc-tsserver (PR #493)')
392
- console.log(' - Install: cd ~/.config/coc/extensions && npm install ChuYanLon/coc-tsserver')
365
+ console.log(` Steps: ${steps.map(s => s.type).join(' ')}`)
366
+ console.log(` Source files: ${result.files.length}`)
367
+ console.log(` Activation: ${activationEvents.join(', ')}`)
368
+
369
+ if (merged.serverBinary) {
370
+ console.log(` Server binary: ${merged.serverBinary.repo}`)
393
371
  }
394
372
 
395
373
  console.log(`\n ${output}/`)
396
- console.log(' ├── src/index.ts ← main entry')
397
- console.log(' ├── package.json ← coc plugin config')
398
- console.log(' ├── esbuild.mjs ← build config')
399
- console.log(' └── src/*.ts converted source')
374
+ console.log(' ├── package.json')
375
+ console.log(' ├── esbuild.mjs')
376
+ console.log(' ├── coc-convert.json')
377
+ if (hasLanguageClient) console.log(' ├── src/index.ts generated entry')
378
+ console.log(' ├── src/*.ts ← converted source')
400
379
  console.log('\n Next:')
401
380
  console.log(` cd ${output}`)
402
381
  console.log(' npm install')
403
- console.log(' npm run build')
382
+ console.log(' npx tsx esbuild.mjs')
404
383
  }
405
384
 
406
- /**
407
- * Scan original source for TypeScript plugin names used by the extension.
408
- * Checks: explicit imports, require() calls, package.json dependencies.
409
- */
410
385
  function walkTsFiles(dir: string): string[] {
411
386
  const files: string[] = []
412
387
  try {
@@ -414,6 +389,7 @@ function walkTsFiles(dir: string): string[] {
414
389
  const full = path.join(dir, entry)
415
390
  const stat = fs.statSync(full)
416
391
  if (stat.isDirectory()) {
392
+ if (entry.startsWith('.') || entry === 'node_modules') continue
417
393
  files.push(...walkTsFiles(full))
418
394
  } else if (entry.endsWith('.ts') || entry.endsWith('.d.ts')) {
419
395
  files.push(full)
@@ -423,79 +399,36 @@ function walkTsFiles(dir: string): string[] {
423
399
  return files
424
400
  }
425
401
 
426
- function scanTypeScriptPlugins(input: string, _result: any): Array<{ name: string; languages: string[]; enableForWorkspaceTypeScriptVersions: boolean }> {
402
+ function scanTypeScriptPlugins(input: string, origPkg: Record<string, any>): Array<{ name: string; languages: string[]; enableForWorkspaceTypeScriptVersions: boolean }> {
403
+ // First check if origPkg already has typescriptServerPlugins configured
404
+ if (origPkg.contributes?.typescriptServerPlugins?.length) {
405
+ return origPkg.contributes.typescriptServerPlugins.map((p: any) => ({
406
+ name: p.name,
407
+ languages: p.languages || [],
408
+ enableForWorkspaceTypeScriptVersions: p.enableForWorkspaceTypeScriptVersions ?? true,
409
+ }))
410
+ }
411
+
412
+ // Otherwise scan source files for plugin references
427
413
  const plugins: Array<{ name: string; languages: string[]; enableForWorkspaceTypeScriptVersions: boolean }> = []
428
- const scanDir = path.join(input, 'src')
414
+ let scanDir = path.join(input, 'src')
415
+ if (!fs.existsSync(scanDir)) scanDir = input
429
416
  if (!fs.existsSync(scanDir)) return plugins
430
417
 
431
- // Collect all .ts file content (recursive)
432
- const allContent = walkTsFiles(scanDir).map(f => fs.readFileSync(f, 'utf-8'))
433
- const content = allContent.join('\n')
434
-
435
- // Check for plugin names in require() / import statements
436
- // Patterns like: @vue/typescript-plugin, @angular/language-service, etc.
437
- const pluginRefs = content.matchAll(/['"](@[^'"]+\/(?:typescript-plugin|language-service)[^'"]*)['"]|['"](\w+(?:-typescript-plugin|-language-service))['"]/g)
438
- for (const m of pluginRefs) {
418
+ const content = walkTsFiles(scanDir).map(f => fs.readFileSync(f, 'utf-8')).join('\n')
419
+ const refs = content.matchAll(/['"](@[^'"]+\/(?:typescript-plugin|language-service)[^'"]*)['"]|['"](\w+(?:-typescript-plugin|-language-service))['"]/g)
420
+ for (const m of refs) {
439
421
  const name = m[1] || m[2]
440
422
  if (name && !plugins.some(p => p.name === name)) {
441
423
  plugins.push({ name, languages: [], enableForWorkspaceTypeScriptVersions: true })
442
424
  }
443
425
  }
444
426
 
445
- // Check package.json for typescript plugin dependencies
446
- const pkgPath = path.join(input, 'package.json')
447
- if (fs.existsSync(pkgPath)) {
448
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
449
- for (const dep of Object.keys(pkg.dependencies || {})) {
450
- if (dep.includes('typescript-plugin') || dep.includes('language-service')) {
451
- if (!plugins.some(p => p.name === dep)) {
452
- plugins.push({ name: dep, languages: [], enableForWorkspaceTypeScriptVersions: true })
453
- }
454
- }
427
+ for (const dep of Object.keys(origPkg.dependencies || {})) {
428
+ if ((dep.includes('typescript-plugin') || dep.includes('language-service')) && !plugins.some(p => p.name === dep)) {
429
+ plugins.push({ name: dep, languages: [], enableForWorkspaceTypeScriptVersions: true })
455
430
  }
456
431
  }
457
432
 
458
433
  return plugins
459
434
  }
460
-
461
- /** Escape string for template literal */
462
- function escapeStr(s: string): string {
463
- return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')
464
- }
465
-
466
- /** Clean up server path: relative → bare package name, subpath → base */
467
- function sanitizeServerPath(p: string): string | null {
468
- if (p.startsWith('../node_modules/')) p = p.replace(/^\.\.\/node_modules\//, '')
469
- if (p.startsWith('./')) return null
470
- if (p.startsWith('../')) return null
471
- if (p.startsWith('@')) {
472
- const parts = p.split('/')
473
- if (parts.length > 2) return parts.slice(0, 2).join('/')
474
- } else {
475
- const parts = p.split('/')
476
- if (parts.length > 1) return parts[0]
477
- }
478
- return p
479
- }
480
-
481
- /**
482
- * Detect language server module names from original source code.
483
- * Returns an array of `require.resolve` argument strings (bare module names).
484
- */
485
- function detectServerModules(input: string, _result: any): string[] {
486
- const seen = new Set<string>()
487
- const serverModules: string[] = []
488
- const scanDir = path.join(input, 'src')
489
- if (fs.existsSync(scanDir)) {
490
- for (const file of walkTsFiles(scanDir)) {
491
- const content = fs.readFileSync(file, 'utf-8')
492
- for (const re of [/(?:require\s*(?:\.\s*resolve)?\s*\(|from\s+)['"]([^'"]+(?:language-server|server|Server|lsp)[^'"]*)['"]\s*\)?/g]) {
493
- for (const m of content.matchAll(re)) {
494
- const name = sanitizeServerPath(m[1])
495
- if (name && !seen.has(name) && !name.includes('${')) { seen.add(name); serverModules.push(name) }
496
- }
497
- }
498
- }
499
- }
500
- return serverModules
501
- }