|
| 1 | +import * as vscode from 'vscode'; |
| 2 | +import { ApiClient, ResourceKey } from '../backend/apiClient'; |
| 3 | + |
| 4 | +/** |
| 5 | + * Default resource class names that trigger completion. |
| 6 | + * These can be overridden by configuration in lrm.json. |
| 7 | + */ |
| 8 | +const DEFAULT_RESOURCE_CLASSES = ['Resources', 'Strings', 'AppResources']; |
| 9 | + |
| 10 | +/** |
| 11 | + * Default localization method names that trigger completion. |
| 12 | + * These can be overridden by configuration in lrm.json. |
| 13 | + */ |
| 14 | +const DEFAULT_LOCALIZATION_METHODS = ['GetString', 'GetLocalizedString', 'Translate', 'L', 'T']; |
| 15 | + |
| 16 | +/** |
| 17 | + * Cache entry with TTL support. |
| 18 | + */ |
| 19 | +interface CacheEntry<T> { |
| 20 | + data: T; |
| 21 | + timestamp: number; |
| 22 | +} |
| 23 | + |
| 24 | +/** |
| 25 | + * Provides IntelliSense autocomplete for localization keys. |
| 26 | + * Triggers on patterns like Resources., GetString(", _localizer[", etc. |
| 27 | + */ |
| 28 | +export class LocalizationCompletionProvider implements vscode.CompletionItemProvider { |
| 29 | + private apiClient: ApiClient; |
| 30 | + private keysCache: CacheEntry<ResourceKey[]> | null = null; |
| 31 | + private configCache: CacheEntry<any> | null = null; |
| 32 | + private readonly cacheTtlMs = 5000; // 5 second cache TTL |
| 33 | + |
| 34 | + constructor(apiClient: ApiClient) { |
| 35 | + this.apiClient = apiClient; |
| 36 | + } |
| 37 | + |
| 38 | + async provideCompletionItems( |
| 39 | + document: vscode.TextDocument, |
| 40 | + position: vscode.Position, |
| 41 | + _token: vscode.CancellationToken, |
| 42 | + _context: vscode.CompletionContext |
| 43 | + ): Promise<vscode.CompletionItem[] | undefined> { |
| 44 | + // Get text from start of line to cursor position |
| 45 | + const lineText = document.lineAt(position).text; |
| 46 | + const textBeforeCursor = lineText.substring(0, position.character); |
| 47 | + |
| 48 | + // Get configuration (resource class names, method names) |
| 49 | + const config = await this.getConfigurationCached(); |
| 50 | + const resourceClasses = config?.scanning?.resourceClassNames || DEFAULT_RESOURCE_CLASSES; |
| 51 | + const localizationMethods = config?.scanning?.localizationMethods || DEFAULT_LOCALIZATION_METHODS; |
| 52 | + |
| 53 | + // Check if we're in a completion trigger context |
| 54 | + const triggerMatch = this.matchTriggerPattern(textBeforeCursor, resourceClasses, localizationMethods, document.languageId); |
| 55 | + |
| 56 | + if (!triggerMatch) { |
| 57 | + return undefined; |
| 58 | + } |
| 59 | + |
| 60 | + // Get keys from cache or API |
| 61 | + const keys = await this.getKeysCached(); |
| 62 | + if (!keys || keys.length === 0) { |
| 63 | + return undefined; |
| 64 | + } |
| 65 | + |
| 66 | + // Find default language (using config for defaultLanguageCode if set) |
| 67 | + const defaultLang = this.findDefaultLanguage(config); |
| 68 | + |
| 69 | + // Filter keys by prefix if user has typed partial key |
| 70 | + const prefix = triggerMatch.prefix.toLowerCase(); |
| 71 | + const filteredKeys = prefix |
| 72 | + ? keys.filter(k => k.key.toLowerCase().startsWith(prefix)) |
| 73 | + : keys; |
| 74 | + |
| 75 | + // Create completion items |
| 76 | + return filteredKeys.map(key => this.createCompletionItem(key, defaultLang, triggerMatch.insertType)); |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Match text against trigger patterns. |
| 81 | + * Returns match info or null if no match. |
| 82 | + */ |
| 83 | + private matchTriggerPattern( |
| 84 | + text: string, |
| 85 | + resourceClasses: string[], |
| 86 | + localizationMethods: string[], |
| 87 | + languageId: string |
| 88 | + ): { prefix: string; insertType: 'property' | 'string' } | null { |
| 89 | + // Build dynamic patterns from configuration |
| 90 | + const classesPattern = resourceClasses.join('|'); |
| 91 | + const methodsPattern = localizationMethods.join('|'); |
| 92 | + |
| 93 | + // Pattern 1: Property access - Resources.Key, @Resources.Key (Razor) |
| 94 | + // Matches: Resources., Strings., AppResources., @Resources., etc. |
| 95 | + const propertyPattern = new RegExp(`(?:^|[^.\\w@])@?(${classesPattern})\\.(\\w*)$`, 'i'); |
| 96 | + const propertyMatch = text.match(propertyPattern); |
| 97 | + if (propertyMatch) { |
| 98 | + return { prefix: propertyMatch[2], insertType: 'property' }; |
| 99 | + } |
| 100 | + |
| 101 | + // Pattern 2: Indexer access - _localizer["Key"], Resources["Key"] |
| 102 | + // Matches: _localizer[", localizer[", Resources[", etc. |
| 103 | + const indexerPattern = new RegExp(`(?:_?[lL]ocalizer|${classesPattern})\\s*\\[\\s*["']([\\w]*)$`, 'i'); |
| 104 | + const indexerMatch = text.match(indexerPattern); |
| 105 | + if (indexerMatch) { |
| 106 | + return { prefix: indexerMatch[1], insertType: 'string' }; |
| 107 | + } |
| 108 | + |
| 109 | + // Pattern 3: Method call - GetString("Key"), T("Key"), Translate("Key") |
| 110 | + // Matches: GetString(", T(", Translate(", etc. |
| 111 | + const methodPattern = new RegExp(`(?:${methodsPattern})\\s*\\(\\s*["']([\\w]*)$`, 'i'); |
| 112 | + const methodMatch = text.match(methodPattern); |
| 113 | + if (methodMatch) { |
| 114 | + return { prefix: methodMatch[1], insertType: 'string' }; |
| 115 | + } |
| 116 | + |
| 117 | + // Pattern 4: IStringLocalizer/IHtmlLocalizer (Razor) - @IStringLocalizer["Key"] |
| 118 | + const razorLocalizerPattern = /I(?:Html|String)Localizer\s*\[\s*["']([\w]*)$/i; |
| 119 | + const razorLocalizerMatch = text.match(razorLocalizerPattern); |
| 120 | + if (razorLocalizerMatch) { |
| 121 | + return { prefix: razorLocalizerMatch[1], insertType: 'string' }; |
| 122 | + } |
| 123 | + |
| 124 | + // Pattern 5: XAML x:Static - {x:Static res:Resources.Key} |
| 125 | + if (languageId === 'xml' || languageId === 'xaml') { |
| 126 | + const xamlPattern = new RegExp(`\\{x:Static\\s+(?:res:)?(${classesPattern})\\.(\\w*)$`, 'i'); |
| 127 | + const xamlMatch = text.match(xamlPattern); |
| 128 | + if (xamlMatch) { |
| 129 | + return { prefix: xamlMatch[2], insertType: 'property' }; |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + return null; |
| 134 | + } |
| 135 | + |
| 136 | + /** |
| 137 | + * Create a completion item for a resource key. |
| 138 | + */ |
| 139 | + private createCompletionItem( |
| 140 | + key: ResourceKey, |
| 141 | + defaultLang: string, |
| 142 | + _insertType: 'property' | 'string' |
| 143 | + ): vscode.CompletionItem { |
| 144 | + const item = new vscode.CompletionItem(key.key, vscode.CompletionItemKind.Constant); |
| 145 | + |
| 146 | + // Show default language value as detail |
| 147 | + const defaultValue = key.values[defaultLang]; |
| 148 | + if (defaultValue) { |
| 149 | + // Truncate long values for display |
| 150 | + item.detail = defaultValue.length > 60 |
| 151 | + ? defaultValue.substring(0, 57) + '...' |
| 152 | + : defaultValue; |
| 153 | + } else { |
| 154 | + item.detail = '(no default value)'; |
| 155 | + } |
| 156 | + |
| 157 | + // Show all translations in documentation popup |
| 158 | + const translations = Object.entries(key.values) |
| 159 | + .filter(([, value]) => value && value.trim()) |
| 160 | + .map(([lang, value]) => `**${lang}**: ${value}`) |
| 161 | + .join('\n\n'); |
| 162 | + |
| 163 | + if (translations) { |
| 164 | + item.documentation = new vscode.MarkdownString(translations); |
| 165 | + } |
| 166 | + |
| 167 | + // For string contexts, just insert the key name (quotes are already there) |
| 168 | + // For property contexts, insert the key name directly |
| 169 | + item.insertText = key.key; |
| 170 | + |
| 171 | + // Sort by key name |
| 172 | + item.sortText = key.key.toLowerCase(); |
| 173 | + |
| 174 | + // Filter text for fuzzy matching |
| 175 | + item.filterText = key.key; |
| 176 | + |
| 177 | + return item; |
| 178 | + } |
| 179 | + |
| 180 | + /** |
| 181 | + * Find the default language code from configuration. |
| 182 | + * Uses defaultLanguageCode from lrm.json if set, otherwise empty string. |
| 183 | + */ |
| 184 | + private findDefaultLanguage(config: any): string { |
| 185 | + return config?.defaultLanguageCode || ''; |
| 186 | + } |
| 187 | + |
| 188 | + /** |
| 189 | + * Get keys from cache or fetch from API. |
| 190 | + */ |
| 191 | + private async getKeysCached(): Promise<ResourceKey[]> { |
| 192 | + const now = Date.now(); |
| 193 | + |
| 194 | + // Return cached data if still valid |
| 195 | + if (this.keysCache && (now - this.keysCache.timestamp) < this.cacheTtlMs) { |
| 196 | + return this.keysCache.data; |
| 197 | + } |
| 198 | + |
| 199 | + try { |
| 200 | + const keys = await this.apiClient.getKeys(); |
| 201 | + this.keysCache = { data: keys, timestamp: now }; |
| 202 | + return keys; |
| 203 | + } catch (error) { |
| 204 | + // Return stale cache if API fails |
| 205 | + if (this.keysCache) { |
| 206 | + return this.keysCache.data; |
| 207 | + } |
| 208 | + return []; |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + /** |
| 213 | + * Get configuration from cache or fetch from API. |
| 214 | + */ |
| 215 | + private async getConfigurationCached(): Promise<any> { |
| 216 | + const now = Date.now(); |
| 217 | + |
| 218 | + // Use longer TTL for config (30 seconds) since it changes rarely |
| 219 | + const configTtlMs = 30000; |
| 220 | + |
| 221 | + if (this.configCache && (now - this.configCache.timestamp) < configTtlMs) { |
| 222 | + return this.configCache.data; |
| 223 | + } |
| 224 | + |
| 225 | + try { |
| 226 | + const config = await this.apiClient.getConfiguration(); |
| 227 | + this.configCache = { data: config, timestamp: now }; |
| 228 | + return config; |
| 229 | + } catch (error) { |
| 230 | + // Return stale cache or empty config if API fails |
| 231 | + return this.configCache?.data || {}; |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + /** |
| 236 | + * Invalidate cache (called when resources change). |
| 237 | + */ |
| 238 | + public invalidateCache(): void { |
| 239 | + this.keysCache = null; |
| 240 | + } |
| 241 | +} |
0 commit comments