source dump of claude code
at main 374 lines 11 kB view raw
1/** 2 * LSP Plugin Recommendation Utility 3 * 4 * Scans installed marketplaces for LSP plugins and recommends plugins 5 * based on file extensions, but ONLY when the LSP binary is already 6 * installed on the system. 7 * 8 * Limitation: Can only detect LSP plugins that declare their servers 9 * inline in the marketplace entry. Plugins with separate .lsp.json files 10 * are not detectable until after installation. 11 */ 12 13import { extname } from 'path' 14import { isBinaryInstalled } from '../binaryCheck.js' 15import { getGlobalConfig, saveGlobalConfig } from '../config.js' 16import { logForDebugging } from '../debug.js' 17import { isPluginInstalled } from './installedPluginsManager.js' 18import { 19 getMarketplace, 20 loadKnownMarketplacesConfig, 21} from './marketplaceManager.js' 22import { 23 ALLOWED_OFFICIAL_MARKETPLACE_NAMES, 24 type PluginMarketplaceEntry, 25} from './schemas.js' 26 27/** 28 * LSP plugin recommendation returned to the caller 29 */ 30export type LspPluginRecommendation = { 31 pluginId: string // "plugin-name@marketplace-name" 32 pluginName: string // Human-readable plugin name 33 marketplaceName: string // Marketplace name 34 description?: string // Plugin description 35 isOfficial: boolean // From official marketplace? 36 extensions: string[] // File extensions this plugin supports 37 command: string // LSP server command (e.g., "typescript-language-server") 38} 39 40// Maximum number of times user can ignore recommendations before we stop showing 41const MAX_IGNORED_COUNT = 5 42 43/** 44 * Check if a marketplace is official (from Anthropic) 45 */ 46function isOfficialMarketplace(name: string): boolean { 47 return ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(name.toLowerCase()) 48} 49 50/** 51 * Internal type for LSP info extracted from plugin manifest 52 */ 53type LspInfo = { 54 extensions: Set<string> 55 command: string 56} 57 58/** 59 * Extract LSP info (extensions and command) from inline lspServers config. 60 * 61 * NOTE: Can only read inline configs, not external .lsp.json files. 62 * String paths are skipped as they reference files only available after installation. 63 * 64 * @param lspServers - The lspServers field from PluginMarketplaceEntry 65 * @returns LSP info with extensions and command, or null if not extractable 66 */ 67function extractLspInfoFromManifest( 68 lspServers: PluginMarketplaceEntry['lspServers'], 69): LspInfo | null { 70 if (!lspServers) { 71 return null 72 } 73 74 // If it's a string path (e.g., "./.lsp.json"), we can't read it from marketplace 75 if (typeof lspServers === 'string') { 76 logForDebugging( 77 '[lspRecommendation] Skipping string path lspServers (not readable from marketplace)', 78 ) 79 return null 80 } 81 82 // If it's an array, process each element 83 if (Array.isArray(lspServers)) { 84 for (const item of lspServers) { 85 // Skip string paths in arrays 86 if (typeof item === 'string') { 87 continue 88 } 89 // Try to extract from inline config object 90 const info = extractFromServerConfigRecord(item) 91 if (info) { 92 return info 93 } 94 } 95 return null 96 } 97 98 // It's an inline config object: Record<string, LspServerConfig> 99 return extractFromServerConfigRecord(lspServers) 100} 101 102/** 103 * Extract LSP info from a server config record (inline object format) 104 */ 105/** 106 * Type guard to check if a value is a record object 107 */ 108function isRecord(value: unknown): value is Record<string, unknown> { 109 return typeof value === 'object' && value !== null 110} 111 112function extractFromServerConfigRecord( 113 serverConfigs: Record<string, unknown>, 114): LspInfo | null { 115 const extensions = new Set<string>() 116 let command: string | null = null 117 118 for (const [_serverName, config] of Object.entries(serverConfigs)) { 119 if (!isRecord(config)) { 120 continue 121 } 122 123 // Get command from first valid server config 124 if (!command && typeof config.command === 'string') { 125 command = config.command 126 } 127 128 // Collect all extensions from extensionToLanguage mapping 129 const extMapping = config.extensionToLanguage 130 if (isRecord(extMapping)) { 131 for (const ext of Object.keys(extMapping)) { 132 extensions.add(ext.toLowerCase()) 133 } 134 } 135 } 136 137 if (!command || extensions.size === 0) { 138 return null 139 } 140 141 return { extensions, command } 142} 143 144/** 145 * Internal type for plugin with LSP info 146 */ 147type LspPluginInfo = { 148 entry: PluginMarketplaceEntry 149 marketplaceName: string 150 extensions: Set<string> 151 command: string 152 isOfficial: boolean 153} 154 155/** 156 * Get all LSP plugins from all installed marketplaces 157 * 158 * @returns Map of pluginId to plugin info with LSP metadata 159 */ 160async function getLspPluginsFromMarketplaces(): Promise< 161 Map<string, LspPluginInfo> 162> { 163 const result = new Map<string, LspPluginInfo>() 164 165 try { 166 const config = await loadKnownMarketplacesConfig() 167 168 for (const marketplaceName of Object.keys(config)) { 169 try { 170 const marketplace = await getMarketplace(marketplaceName) 171 const isOfficial = isOfficialMarketplace(marketplaceName) 172 173 for (const entry of marketplace.plugins) { 174 // Skip plugins without lspServers 175 if (!entry.lspServers) { 176 continue 177 } 178 179 const lspInfo = extractLspInfoFromManifest(entry.lspServers) 180 if (!lspInfo) { 181 continue 182 } 183 184 const pluginId = `${entry.name}@${marketplaceName}` 185 result.set(pluginId, { 186 entry, 187 marketplaceName, 188 extensions: lspInfo.extensions, 189 command: lspInfo.command, 190 isOfficial, 191 }) 192 } 193 } catch (error) { 194 logForDebugging( 195 `[lspRecommendation] Failed to load marketplace ${marketplaceName}: ${error}`, 196 ) 197 } 198 } 199 } catch (error) { 200 logForDebugging( 201 `[lspRecommendation] Failed to load marketplaces config: ${error}`, 202 ) 203 } 204 205 return result 206} 207 208/** 209 * Find matching LSP plugins for a file path. 210 * 211 * Returns recommendations for plugins that: 212 * 1. Support the file's extension 213 * 2. Have their LSP binary installed on the system 214 * 3. Are not already installed 215 * 4. Are not in the user's "never suggest" list 216 * 217 * Results are sorted with official marketplace plugins first. 218 * 219 * @param filePath - Path to the file to find LSP plugins for 220 * @returns Array of matching plugin recommendations (empty if none or disabled) 221 */ 222export async function getMatchingLspPlugins( 223 filePath: string, 224): Promise<LspPluginRecommendation[]> { 225 // Check if globally disabled 226 if (isLspRecommendationsDisabled()) { 227 logForDebugging('[lspRecommendation] Recommendations are disabled') 228 return [] 229 } 230 231 // Extract file extension 232 const ext = extname(filePath).toLowerCase() 233 if (!ext) { 234 logForDebugging('[lspRecommendation] No file extension found') 235 return [] 236 } 237 238 logForDebugging(`[lspRecommendation] Looking for LSP plugins for ${ext}`) 239 240 // Get all LSP plugins from marketplaces 241 const allLspPlugins = await getLspPluginsFromMarketplaces() 242 243 // Get config for filtering 244 const config = getGlobalConfig() 245 const neverPlugins = config.lspRecommendationNeverPlugins ?? [] 246 247 // Filter to matching plugins 248 const matchingPlugins: Array<{ info: LspPluginInfo; pluginId: string }> = [] 249 250 for (const [pluginId, info] of allLspPlugins) { 251 // Check extension match 252 if (!info.extensions.has(ext)) { 253 continue 254 } 255 256 // Filter: not in "never" list 257 if (neverPlugins.includes(pluginId)) { 258 logForDebugging( 259 `[lspRecommendation] Skipping ${pluginId} (in never suggest list)`, 260 ) 261 continue 262 } 263 264 // Filter: not already installed 265 if (isPluginInstalled(pluginId)) { 266 logForDebugging( 267 `[lspRecommendation] Skipping ${pluginId} (already installed)`, 268 ) 269 continue 270 } 271 272 matchingPlugins.push({ info, pluginId }) 273 } 274 275 // Filter: binary must be installed (async check) 276 const pluginsWithBinary: Array<{ info: LspPluginInfo; pluginId: string }> = [] 277 278 for (const { info, pluginId } of matchingPlugins) { 279 const binaryExists = await isBinaryInstalled(info.command) 280 if (binaryExists) { 281 pluginsWithBinary.push({ info, pluginId }) 282 logForDebugging( 283 `[lspRecommendation] Binary '${info.command}' found for ${pluginId}`, 284 ) 285 } else { 286 logForDebugging( 287 `[lspRecommendation] Skipping ${pluginId} (binary '${info.command}' not found)`, 288 ) 289 } 290 } 291 292 // Sort: official marketplaces first 293 pluginsWithBinary.sort((a, b) => { 294 if (a.info.isOfficial && !b.info.isOfficial) return -1 295 if (!a.info.isOfficial && b.info.isOfficial) return 1 296 return 0 297 }) 298 299 // Convert to recommendations 300 return pluginsWithBinary.map(({ info, pluginId }) => ({ 301 pluginId, 302 pluginName: info.entry.name, 303 marketplaceName: info.marketplaceName, 304 description: info.entry.description, 305 isOfficial: info.isOfficial, 306 extensions: Array.from(info.extensions), 307 command: info.command, 308 })) 309} 310 311/** 312 * Add a plugin to the "never suggest" list 313 * 314 * @param pluginId - Plugin ID to never suggest again 315 */ 316export function addToNeverSuggest(pluginId: string): void { 317 saveGlobalConfig(currentConfig => { 318 const current = currentConfig.lspRecommendationNeverPlugins ?? [] 319 if (current.includes(pluginId)) { 320 return currentConfig 321 } 322 return { 323 ...currentConfig, 324 lspRecommendationNeverPlugins: [...current, pluginId], 325 } 326 }) 327 logForDebugging(`[lspRecommendation] Added ${pluginId} to never suggest`) 328} 329 330/** 331 * Increment the ignored recommendation count. 332 * After MAX_IGNORED_COUNT ignores, recommendations are disabled. 333 */ 334export function incrementIgnoredCount(): void { 335 saveGlobalConfig(currentConfig => { 336 const newCount = (currentConfig.lspRecommendationIgnoredCount ?? 0) + 1 337 return { 338 ...currentConfig, 339 lspRecommendationIgnoredCount: newCount, 340 } 341 }) 342 logForDebugging('[lspRecommendation] Incremented ignored count') 343} 344 345/** 346 * Check if LSP recommendations are disabled. 347 * Disabled when: 348 * - User explicitly disabled via config 349 * - User has ignored MAX_IGNORED_COUNT recommendations 350 */ 351export function isLspRecommendationsDisabled(): boolean { 352 const config = getGlobalConfig() 353 return ( 354 config.lspRecommendationDisabled === true || 355 (config.lspRecommendationIgnoredCount ?? 0) >= MAX_IGNORED_COUNT 356 ) 357} 358 359/** 360 * Reset the ignored count (useful if user re-enables recommendations) 361 */ 362export function resetIgnoredCount(): void { 363 saveGlobalConfig(currentConfig => { 364 const currentCount = currentConfig.lspRecommendationIgnoredCount ?? 0 365 if (currentCount === 0) { 366 return currentConfig 367 } 368 return { 369 ...currentConfig, 370 lspRecommendationIgnoredCount: 0, 371 } 372 }) 373 logForDebugging('[lspRecommendation] Reset ignored count') 374}