/ utils / plugins / lspRecommendation.ts
lspRecommendation.ts
  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  
 13  import { extname } from 'path'
 14  import { isBinaryInstalled } from '../binaryCheck.js'
 15  import { getGlobalConfig, saveGlobalConfig } from '../config.js'
 16  import { logForDebugging } from '../debug.js'
 17  import { isPluginInstalled } from './installedPluginsManager.js'
 18  import {
 19    getMarketplace,
 20    loadKnownMarketplacesConfig,
 21  } from './marketplaceManager.js'
 22  import {
 23    ALLOWED_OFFICIAL_MARKETPLACE_NAMES,
 24    type PluginMarketplaceEntry,
 25  } from './schemas.js'
 26  
 27  /**
 28   * LSP plugin recommendation returned to the caller
 29   */
 30  export 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
 41  const MAX_IGNORED_COUNT = 5
 42  
 43  /**
 44   * Check if a marketplace is official (from Anthropic)
 45   */
 46  function 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   */
 53  type 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   */
 67  function 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   */
108  function isRecord(value: unknown): value is Record<string, unknown> {
109    return typeof value === 'object' && value !== null
110  }
111  
112  function 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   */
147  type 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   */
160  async 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   */
222  export 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   */
316  export 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   */
334  export 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   */
351  export 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   */
362  export 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  }