Skip to content

Commit 2800590

Browse files
committed
Add IntelliSense autocomplete for localization keys
1 parent a775aa9 commit 2800590

4 files changed

Lines changed: 280 additions & 6 deletions

File tree

vscode-extension/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Manage .NET .resx localization files with real-time validation, translation, and code scanning.
44

5-
![Dashboard](images/dashboard.png)
5+
![Dashboard](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/dashboard.png)
66

77
## Features
88

@@ -12,27 +12,32 @@ View translation coverage, validation issues, and quick actions at a glance.
1212
### Resource Editor
1313
Edit all languages side-by-side with search, filtering, and bulk actions.
1414

15-
![Resource Editor](images/editor.png)
15+
![Resource Editor](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/editor.png)
1616

1717
### Real-time Code Diagnostics
1818
Get inline warnings for missing localization keys as you type.
1919

20-
![Quick Fix](images/quick-fix.png)
20+
![Quick Fix](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/quick-fix.png)
21+
22+
### IntelliSense Autocomplete
23+
Get autocomplete suggestions for localization keys as you type. Supports `Resources.`, `GetString("`, `_localizer["` and more patterns.
24+
25+
![Autocomplete](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/autocomplete.png)
2126

2227
### Code Scanning
2328
Find missing and unused keys across your codebase.
2429

25-
![Code Scan](images/code-scan.png)
30+
![Code Scan](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/code-scan.png)
2631

2732
### Key References
2833
See exactly where each key is used in your code.
2934

30-
![References](images/references.png)
35+
![References](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/references.png)
3136

3237
### Resource Tree
3338
Browse keys organized by resource file in the Explorer sidebar.
3439

35-
![Tree View](images/tree-view.png)
40+
![Tree View](https://raw.githubusercontent.com/nickprotop/LocalizationManager/main/vscode-extension/images/tree-view.png)
3641

3742
### Translation
3843
Translate missing values using free or paid providers.
338 KB
Loading

vscode-extension/src/extension.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CodeDiagnosticProvider } from './providers/codeDiagnostics';
55
import { ResxDiagnosticProvider } from './providers/resxDiagnostics';
66
import { ResourceTreeView } from './views/resourceTreeView';
77
import { QuickFixProvider } from './providers/quickFix';
8+
import { LocalizationCompletionProvider } from './providers/completionProvider';
89
import { ResourceEditorPanel } from './views/resourceEditor';
910
import { StatusBarManager } from './views/statusBar';
1011
import { DashboardPanel } from './views/dashboard';
@@ -16,6 +17,7 @@ let statusBarManager: StatusBarManager;
1617
let codeDiagnostics: CodeDiagnosticProvider;
1718
let resxDiagnostics: ResxDiagnosticProvider;
1819
let resourceTreeView: ResourceTreeView;
20+
let completionProvider: LocalizationCompletionProvider;
1921
let outputChannel: vscode.OutputChannel;
2022

2123
export async function activate(context: vscode.ExtensionContext) {
@@ -61,6 +63,23 @@ export async function activate(context: vscode.ExtensionContext) {
6163
)
6264
);
6365

66+
// Register Completion provider for localization key autocomplete
67+
completionProvider = new LocalizationCompletionProvider(apiClient);
68+
context.subscriptions.push(
69+
vscode.languages.registerCompletionItemProvider(
70+
[
71+
{ language: 'csharp', scheme: 'file' },
72+
{ language: 'razor', scheme: 'file' },
73+
{ language: 'aspnetcorerazor', scheme: 'file' },
74+
{ pattern: '**/*.cshtml' },
75+
{ pattern: '**/*.xaml' }
76+
],
77+
completionProvider,
78+
'.', '"', "'", '[' // Trigger characters
79+
)
80+
);
81+
outputChannel.appendLine('Completion provider registered');
82+
6483
// Enable diagnostic providers based on settings
6584
const config = vscode.workspace.getConfiguration('lrm');
6685
const enableRealtimeScan = config.get<boolean>('enableRealtimeScan', true); // Now default true
@@ -181,16 +200,25 @@ function setupEventListeners(context: vscode.ExtensionContext, enableRealtimeSca
181200
resxWatcher.onDidChange(async () => {
182201
await resxDiagnostics.validateAllResources();
183202
await resourceTreeView.loadResources();
203+
if (completionProvider) {
204+
completionProvider.invalidateCache();
205+
}
184206
});
185207

186208
resxWatcher.onDidCreate(async () => {
187209
await resxDiagnostics.validateAllResources();
188210
await resourceTreeView.loadResources();
211+
if (completionProvider) {
212+
completionProvider.invalidateCache();
213+
}
189214
});
190215

191216
resxWatcher.onDidDelete(async () => {
192217
await resxDiagnostics.validateAllResources();
193218
await resourceTreeView.loadResources();
219+
if (completionProvider) {
220+
completionProvider.invalidateCache();
221+
}
194222
});
195223

196224
context.subscriptions.push(resxWatcher);
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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

Comments
 (0)