From 55a96c9480e41a6bcb263ebe5a0672f2753a1a68 Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Tue, 21 Apr 2026 23:53:31 +0300 Subject: [PATCH 1/8] feat: add "codelens" setting I'm not exactly sure if that setting is supposed to be enabled by default. --- package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 17bea17..1e95716 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "Other" ], "activationEvents": [ - "onLanguage:json" + "onLanguage:json", + "onLanguage:dart" ], "icon": "media/logo.png", "main": "./out/extension.js", @@ -79,6 +80,11 @@ } } ] + }, + "arb-editor.enableAppLocalizationsCodeLens": { + "type": "boolean", + "default": true, + "description": "Enable CodeLens for AppLocalizations member access in Dart files." } } } From 2dff9ce145413c7311291bd6842cb53c82a3c366 Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Tue, 21 Apr 2026 23:59:54 +0300 Subject: [PATCH 2/8] chore: force-use semicolons My "format on save" VSCode setting was removing those semicolons. I've had to enable that in the eslint config so there wouldn't be a lot of unnecessary changes. --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index c1b96c6..cf4f9ab 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,7 @@ export default [ "eqeqeq": "warn", "no-throw-literal": "warn", "@typescript-eslint/naming-convention": "warn", - "semi": "off", + "semi": ["warn", "always"], } } ]; From e91a70ebb22f9de961bb03b37770b754cee50c1a Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 00:20:30 +0300 Subject: [PATCH 3/8] feat: show codelens on all `AppLocalization` uses --- src/codelens.ts | 337 +++++++++++++++++++++++++++++++++++++++++++++++ src/extension.ts | 11 ++ 2 files changed, 348 insertions(+) create mode 100644 src/codelens.ts diff --git a/src/codelens.ts b/src/codelens.ts new file mode 100644 index 0000000..e1b21e3 --- /dev/null +++ b/src/codelens.ts @@ -0,0 +1,337 @@ +// Copyright 2026 Google LLC + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// https://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import * as vscode from 'vscode'; + +/** + * Pattern to find member access expressions: + * `identifier.member`, `identifier?.member` or `identifier!.member`. + */ +const memberAccessPattern = /\b([a-zA-Z_][a-zA-Z0-9_]*)([?!])?\.([a-zA-Z_][a-zA-Z0-9_]*)/g; + +/** + * Pattern to find direct AppLocalizations access: + * `AppLocalizations.of(context)!.member` or `AppLocalizations.of(context)?.member`. + */ +const directAppLocalizationsAccessPattern = /\bAppLocalizations\.of\s*\(\s*context\s*\)\s*[?!]\s*\.\s*([a-zA-Z_][a-zA-Z0-9_]*)/g; + +type MemberAccessCandidate = { + identifier: string; + member: string; + offset: number; + requiresHoverCheck: boolean; +}; + +/** + * CodeLens provider class. + */ +export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider { + private readonly codeLensEmitter = new vscode.EventEmitter(); + public readonly onDidChangeCodeLenses = this.codeLensEmitter.event; + + /** + * Refreshes CodeLens when `arb-editor.enableAppLocalizationsCodeLens` setting is enabled + */ + constructor() { + vscode.workspace.onDidChangeConfiguration(event => { + if (!event.affectsConfiguration('arb-editor.enableAppLocalizationsCodeLens')) return; + + this.codeLensEmitter.fire(); + }); + } + + /** + * Provides code lenses for Dart files when the AppLocalizations feature is enabled. + */ + provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken + ): vscode.CodeLens[] | Thenable { + if (document.languageId !== 'dart') return []; + + const config = vscode.workspace.getConfiguration('arb-editor'); + const enabled = config.get('enableAppLocalizationsCodeLens', true); + if (!enabled) return []; + + return this.findAppLocalizations(document, token); + } + + /** + * Finds member-access expressions and asks the Dart language server for hover/type + * information to determine whether the receiver is an AppLocalizations instance. + */ + private async findAppLocalizations( + document: vscode.TextDocument, + token: vscode.CancellationToken + ): Promise { + const source = document.getText(); + const candidates = getMemberAccessCandidates(source); + const codeLenses: vscode.CodeLens[] = []; + + for (const candidate of candidates) { + if (token.isCancellationRequested) break; + + const offset = candidate.offset; + const position = document.positionAt(offset); + + if (!candidate.requiresHoverCheck) { + const lensPosition = document.positionAt(offset); + const range = new vscode.Range(lensPosition, lensPosition); + + codeLenses.push( + new vscode.CodeLens(range, { + title: 'Ah-ha! AppLocalizations is below me :)', + command: 'arb-editor.noopCodeLens' + }) + ); + + continue; + } + + try { + const hovers = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + document.uri, + position + ); + + if (hovers && hovers.length > 0 && isAppLocalizationsType(hovers[0])) { + const lensPosition = document.positionAt(offset); + const range = new vscode.Range(lensPosition, lensPosition); + + codeLenses.push( + new vscode.CodeLens(range, { + title: 'Ah-ha! AppLocalizations is below me :)', + command: 'arb-editor.noopCodeLens' + }) + ); + } + } catch { + // Hover provider failed, skip this match + } + } + + return codeLenses; + } +} + +/** + * Collects member-access candidates from source while skipping class/static style + * receivers like `AppLocalizations.of`. + */ +export function getMemberAccessCandidates(source: string): MemberAccessCandidate[] { + const sanitized = sanitizeDartSource(source); + const candidates: MemberAccessCandidate[] = []; + + for (const match of sanitized.matchAll(directAppLocalizationsAccessPattern)) { + const member = match[1]; + const offset = match.index; + if (!member || offset === undefined) continue; + + candidates.push({ + identifier: 'AppLocalizations', + member, + offset, + requiresHoverCheck: false, + }); + } + + for (const match of sanitized.matchAll(memberAccessPattern)) { + const identifier = match[1]; + const member = match[3]; + const offset = match.index; + if (!identifier || !member || offset === undefined) continue; + if (isLikelyTypeOrStaticReceiver(identifier)) continue; + + candidates.push({ + identifier, + member, + offset, + requiresHoverCheck: true + }); + } + + return candidates; +} + +/** + * Treats UpperCamelCase receivers as type/static access and excludes + * them from instance candidates like `AppLocalizations.of`. + */ +function isLikelyTypeOrStaticReceiver(identifier: string): boolean { + return /^[A-Z]/.test(identifier); +} + +/** + * Returns true when hover text indicates the symbol type includes AppLocalizations. + */ +function isAppLocalizationsType(hover: vscode.Hover): boolean { + const hoverText = hover.contents + .map(c => (typeof c === 'string' ? c : c.value)) + .join(' '); + + return hoverText.includes("AppLocalizations"); +} + +/** + * Produces a source-like string where comments and string contents are masked with + * spaces (newlines preserved) to avoid false positives in literals/comments. + */ +function sanitizeDartSource(source: string): string { + let result = ''; + let i = 0; + let blockCommentDepth = 0; + + while (i < source.length) { + if (blockCommentDepth > 0) { + if (source[i] === '/' && source[i + 1] === '*') { + result += ' '; + i += 2; + blockCommentDepth += 1; + + continue; + } + if (source[i] === '*' && source[i + 1] === '/') { + result += ' '; + i += 2; + blockCommentDepth -= 1; + + continue; + } + result += source[i] === '\n' ? '\n' : ' '; + i += 1; + + continue; + } + + if (source[i] === '/' && source[i + 1] === '/') { + result += ' '; + i += 2; + while (i < source.length && source[i] !== '\n') { + result += ' '; + i += 1; + } + + continue; + } + + if (source[i] === '/' && source[i + 1] === '*') { + result += ' '; + i += 2; + blockCommentDepth = 1; + + continue; + } + + const quote = detectStringStart(source, i); + if (quote) { + i = maskString(source, i, quote, resultAppender => result += resultAppender); + + continue; + } + + result += source[i]; + i += 1; + } + + return result; +} + +/** + * Detects whether a Dart string starts at the current index, including raw and + * triple-quoted forms, and returns metadata needed to mask it. + */ +function detectStringStart(source: string, i: number): { raw: boolean; triple: boolean; quote: '\'' | '"'; prefixLength: number; } | undefined { + const char = source[i]; + if (char !== '\'' && char !== '"' && char !== 'r' && char !== 'R') return undefined; + + if (char === '\'' || char === '"') { + return { + raw: false, + triple: source[i + 1] === char && source[i + 2] === char, + quote: char, + prefixLength: 0, + }; + } + + const quote = source[i + 1]; + if (quote !== '\'' && quote !== '"') return undefined; + + const previous = source[i - 1] ?? ''; + if (/[A-Za-z0-9_]/.test(previous)) return undefined; + + return { + raw: true, + triple: source[i + 2] === quote && source[i + 3] === quote, + quote, + prefixLength: 1, + }; +} + +/** + * Replaces a string literal region with spaces (preserving line breaks) and returns + * the next index after the closing quote sequence. + */ +function maskString( + source: string, + start: number, + stringType: { raw: boolean; triple: boolean; quote: '\'' | '"'; prefixLength: number; }, + append: (text: string) => void, +): number { + let i = start; + + for (let j = 0; j < stringType.prefixLength; j += 1) { + append(' '); + + i += 1; + } + + if (stringType.triple) { + append(' '); + + i += 3; + } else { + append(' '); + + i += 1; + } + + while (i < source.length) { + if (!stringType.raw && !stringType.triple && source[i] === '\\') { + append(' '); + i += 1; + + if (i < source.length) { + append(source[i] === '\n' ? '\n' : ' '); + i += 1; + } + + continue; + } + + if (stringType.triple) { + if (source[i] === stringType.quote && source[i + 1] === stringType.quote && source[i + 2] === stringType.quote) { + append(' '); + return i + 3; + } + } else if (source[i] === stringType.quote) { + append(' '); + return i + 1; + } + + append(source[i] === '\n' ? '\n' : ' '); + i += 1; + } + + return i; +} diff --git a/src/extension.ts b/src/extension.ts index 3b465ce..5a59ca9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ let pendingDecorations: NodeJS.Timeout | undefined; import path = require('path'); import * as vscode from 'vscode'; +import { AppLocalizationsCodeLensProvider } from './codelens'; import { CodeActions } from './codeactions'; import { Decorator } from './decorate'; import { Diagnostics } from './diagnose'; @@ -37,6 +38,7 @@ export async function activate(context: vscode.ExtensionContext) { const diagnostics = new Diagnostics(context); const parser = new Parser(); const quickfixes = new CodeActions(); + const appLocalizationsCodeLenses = new AppLocalizationsCodeLensProvider(); let commonMessageList: MessageList | undefined; // decorate when changing the active editor editor @@ -77,6 +79,15 @@ export async function activate(context: vscode.ExtensionContext) { ), ); + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: 'dart' }, + appLocalizationsCodeLenses, + ), + ); + + context.subscriptions.push(vscode.commands.registerCommand('arb-editor.noopCodeLens', () => undefined)); + // decorate the active editor now handleFile(vscode.window.activeTextEditor); From 5c7050ee415b06738c3a1eb725ed0a29e6d21a87 Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 00:20:34 +0300 Subject: [PATCH 4/8] test: add tests --- src/test/suite/codelens.test.ts | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/test/suite/codelens.test.ts diff --git a/src/test/suite/codelens.test.ts b/src/test/suite/codelens.test.ts new file mode 100644 index 0000000..de17518 --- /dev/null +++ b/src/test/suite/codelens.test.ts @@ -0,0 +1,157 @@ +// Copyright 2026 Google LLC + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// https://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import * as assert from 'assert'; +import { getMemberAccessCandidates } from '../../codelens'; + +suite('AppLocalizations CodeLens', () => { + test('does not match AppLocalizations.of in assignment', () => { + const source = ` +final l10n = AppLocalizations.of(context)!; +`; + + const candidates = getMemberAccessCandidates(source); + assert.strictEqual(candidates.length, 0); + }); + + test('does not match static access on AppLocalizations', () => { + const source = ` +final delegates = AppLocalizations.localizationDelegates; +`; + + const candidates = getMemberAccessCandidates(source); + assert.strictEqual(candidates.length, 0); + }); + + test('matches instance member access for l10n variables', () => { + const source = ` +Text(l10n.helloWorld); +print(someLoc.abcValue); +print(maybeLoc?.abcValue); +print(maybeLoc!.abcValue); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['l10n.helloWorld', 'someLoc.abcValue', 'maybeLoc.abcValue', 'maybeLoc.abcValue'] + ); + }); + + test('ignores matches in comments and strings', () => { + const source = ` +// l10n.helloWorld should be ignored +final text = "maybeLoc.abcValue should be ignored"; +print(realLoc.magic); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['realLoc.magic'] + ); + }); + + test('ignores block comments and triple-quoted strings', () => { + const source = ` +/* l10n.blocked */ +final text = ''' +maybeLoc.ignoredInTriple +'''; +print(okLoc.allowed); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['okLoc.allowed'] + ); + }); + + test('ignores uppercase type-like receivers in general', () => { + const source = ` +Theme.of(context); +MyType.someStatic; +print(realLoc.actualUsage); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['realLoc.actualUsage'] + ); + }); + + test('supports identifiers with underscores and digits', () => { + const source = ` +print(l10n_2.hello_world_3); +print(_localizations.value_1); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['l10n_2.hello_world_3', '_localizations.value_1'] + ); + }); + + test('captures chained access only at first hop', () => { + const source = ` +print(l10n.helloWorld.length); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['l10n.helloWorld'] + ); + }); + + test('keeps optional and non-null variants as same candidate shape', () => { + const source = ` +print(loc.value); +print(loc?.value); +print(loc!.value); +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['loc.value', 'loc.value', 'loc.value'] + ); + }); + + test('matches direct AppLocalizations.of(context)! member access', () => { + const source = ` +AppLocalizations.of(context)!.abc; +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['AppLocalizations.abc'] + ); + }); + + test('matches direct AppLocalizations.of(context)? member access', () => { + const source = ` +AppLocalizations.of(context)?.abc; +`; + + const candidates = getMemberAccessCandidates(source); + assert.deepStrictEqual( + candidates.map(candidate => `${candidate.identifier}.${candidate.member}`), + ['AppLocalizations.abc'] + ); + }); +}); From b64688f345290524d961ec78f87a564dd93fefcc Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 00:35:04 +0300 Subject: [PATCH 5/8] feat: settings for ARB display language --- package.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/package.json b/package.json index 1e95716..481c580 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,20 @@ "type": "boolean", "default": true, "description": "Enable CodeLens for AppLocalizations member access in Dart files." + }, + "arb-editor.appLocalizationsCodeLensLanguageMode": { + "type": "string", + "enum": [ + "definedByYaml", + "custom" + ], + "default": "definedByYaml", + "markdownDescription": "Controls language source for CodeLens text preview.\nUse `#arb-editor.appLocalizationsCodeLensCustomLanguage#` to set the custom language value." + }, + "arb-editor.appLocalizationsCodeLensCustomLanguage": { + "type": "string", + "default": "en", + "markdownDescription": "Custom language code for CodeLens text (for example: `en`, `ru`, `fr`).\n\nThis field only works when `#arb-editor.appLocalizationsCodeLensLanguageMode#` is set to `custom`." } } } From b543026bb4d2ae31e2fc8d05676ffb8afab1eb7e Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 00:53:14 +0300 Subject: [PATCH 6/8] refactor: implement `l10n.yaml` file caching, display yaml/setting language in codelens --- src/codelens.ts | 119 +++++++++++++++++++++++++++++++- src/messageParser.ts | 13 +--- src/project.ts | 78 ++++++++++++++++----- src/test/suite/codelens.test.ts | 41 ++++++++++- 4 files changed, 219 insertions(+), 32 deletions(-) diff --git a/src/codelens.ts b/src/codelens.ts index e1b21e3..e2d0096 100644 --- a/src/codelens.ts +++ b/src/codelens.ts @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. import * as vscode from 'vscode'; +import path = require('path'); +import YAML = require('yaml'); +import { getL10nYamlContent, locateL10nYaml } from './project'; /** * Pattern to find member access expressions: @@ -32,6 +35,8 @@ type MemberAccessCandidate = { requiresHoverCheck: boolean; }; +type CodeLensLanguageMode = 'definedByYaml' | 'custom'; + /** * CodeLens provider class. */ @@ -44,7 +49,13 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider */ constructor() { vscode.workspace.onDidChangeConfiguration(event => { - if (!event.affectsConfiguration('arb-editor.enableAppLocalizationsCodeLens')) return; + if ( + !event.affectsConfiguration('arb-editor.enableAppLocalizationsCodeLens') + && !event.affectsConfiguration('arb-editor.appLocalizationsCodeLensLanguageMode') + && !event.affectsConfiguration('arb-editor.appLocalizationsCodeLensCustomLanguage') + ) { + return; + } this.codeLensEmitter.fire(); }); @@ -76,6 +87,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider ): Promise { const source = document.getText(); const candidates = getMemberAccessCandidates(source); + const displayLanguage = resolveCodeLensDisplayLanguage(document); const codeLenses: vscode.CodeLens[] = []; for (const candidate of candidates) { @@ -90,7 +102,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider codeLenses.push( new vscode.CodeLens(range, { - title: 'Ah-ha! AppLocalizations is below me :)', + title: getCodeLensTitle(displayLanguage), command: 'arb-editor.noopCodeLens' }) ); @@ -111,7 +123,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider codeLenses.push( new vscode.CodeLens(range, { - title: 'Ah-ha! AppLocalizations is below me :)', + title: getCodeLensTitle(displayLanguage), command: 'arb-editor.noopCodeLens' }) ); @@ -125,6 +137,10 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider } } +function getCodeLensTitle(language: string): string { + return `Ah-ha! AppLocalizations is below me :) [${language}]`; +} + /** * Collects member-access candidates from source while skipping class/static style * receivers like `AppLocalizations.of`. @@ -172,6 +188,103 @@ function isLikelyTypeOrStaticReceiver(identifier: string): boolean { return /^[A-Z]/.test(identifier); } +/** + * Resolves the display language from settings and optional l10n.yaml data. + */ +export function resolveDisplayLanguage(options: { + languageMode?: string; + customLanguage?: string; + l10nYamlContent?: string; +}): string { + const mode = options.languageMode === 'custom' ? 'custom' : 'definedByYaml'; + const customLanguage = (options.customLanguage ?? '').trim(); + + if (mode === 'custom' && customLanguage) { + return customLanguage; + } + + const yamlLanguage = extractLanguageFromL10nYamlContent(options.l10nYamlContent); + if (yamlLanguage) { + return yamlLanguage; + } + + if (customLanguage) { + return customLanguage; + } + + return 'en'; +} + +/** + * Extracts a locale code from l10n.yaml settings. + */ +export function extractLanguageFromL10nYamlContent(content?: string): string | undefined { + if (!content) { + return undefined; + } + + let parsed: Record | undefined; + try { + parsed = YAML.parse(content) as Record; + } catch { + return undefined; + } + + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + + const templateArbFile = typeof parsed['template-arb-file'] === 'string' ? parsed['template-arb-file'] : undefined; + const preferredSupportedLocales = typeof parsed['preferred-supported-locales'] === 'string' ? parsed['preferred-supported-locales'] : undefined; + const outputLocalizationFile = typeof parsed['output-localization-file'] === 'string' ? parsed['output-localization-file'] : undefined; + + const templateLanguage = extractLanguageFromFileName(templateArbFile, '.arb'); + if (templateLanguage) { + return templateLanguage; + } + + const preferredLanguage = extractFirstLocale(preferredSupportedLocales); + if (preferredLanguage) { + return preferredLanguage; + } + + return extractLanguageFromFileName(outputLocalizationFile, '.dart'); +} + +function resolveCodeLensDisplayLanguage(document: vscode.TextDocument): string { + const config = vscode.workspace.getConfiguration('arb-editor'); + const languageMode = config.get('appLocalizationsCodeLensLanguageMode', 'definedByYaml'); + const customLanguage = config.get('appLocalizationsCodeLensCustomLanguage', ''); + + const l10nYamlPath = locateL10nYaml(path.dirname(document.uri.fsPath)); + const l10nYamlContent = getL10nYamlContent(l10nYamlPath); + + return resolveDisplayLanguage({ + languageMode, + customLanguage, + l10nYamlContent, + }); +} + +function extractLanguageFromFileName(fileName: string | undefined, suffix: '.arb' | '.dart'): string | undefined { + if (!fileName) return undefined; + + const normalized = fileName.trim(); + const matcher = suffix === '.arb' + ? /_([A-Za-z0-9_-]+)\.arb$/ + : /_([A-Za-z0-9_-]+)\.dart$/; + + const match = normalized.match(matcher); + return match?.[1]; +} + +function extractFirstLocale(rawLocales: string | undefined): string | undefined { + if (!rawLocales) return undefined; + + const match = rawLocales.match(/[A-Za-z]{2,3}(?:[_-][A-Za-z0-9]+)*/); + return match?.[0]; +} + /** * Returns true when hover text indicates the symbol type includes AppLocalizations. */ diff --git a/src/messageParser.ts b/src/messageParser.ts index a655707..0e17212 100644 --- a/src/messageParser.ts +++ b/src/messageParser.ts @@ -13,13 +13,12 @@ import * as vscode from 'vscode'; import { JSONPath, visit } from 'jsonc-parser'; import XRegExp = require('xregexp'); -import { locateL10nYaml } from './project'; +import { getParsedL10nYaml, locateL10nYaml } from './project'; import { L10nYaml } from './extension'; import { Diagnostics } from './diagnose'; import { Decorator } from './decorate'; import { CodeActions } from './codeactions'; import path = require('path'); -import YAML = require('yaml'); import fs = require('fs'); export class Parser { @@ -228,7 +227,7 @@ export class Parser { const l10nYamlPath = locateL10nYaml(editor.document.uri.fsPath); const l10nOptions = l10nYamlPath - ? parseYaml(l10nYamlPath) + ? getParsedL10nYaml(l10nYamlPath) : undefined; const [messageList, errors] = this.parse(editor.document.getText(), l10nOptions)!; @@ -288,14 +287,6 @@ function matchCurlyBrackets(v: StringLiteral, l10nOptions?: L10nYaml): MatchRecu return values; } -function parseYaml(uri: string): L10nYaml | undefined { - if (!fs.existsSync(uri)) { - return; - } - const yaml = fs.readFileSync(uri, "utf8"); - return YAML.parse(yaml) as L10nYaml; -} - export function getUnescapedRegions(expression: string): [number, number][] { const unEscapedRegions: [number, number][] = []; diff --git a/src/project.ts b/src/project.ts index 3991e93..4198c58 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,39 +1,83 @@ import * as fs from "fs"; import * as path from "path"; import { Uri, workspace } from "vscode"; +import YAML = require('yaml'); export const UPGRADE_TO_WORKSPACE_FOLDERS = "Mark Projects as Workspace Folders"; +type L10nYamlCacheEntry = { + mtimeMs: number; + content: string; + parsed: Record | undefined; +}; + +const l10nYamlCache = new Map(); + export function locateL10nYaml(folder: string): string | undefined { - if (!folder || (!isWithinWorkspace(folder) && workspace.workspaceFolders?.length)) { - return undefined; - } + if (!folder || (!isWithinWorkspace(folder) && workspace.workspaceFolders?.length)) return undefined; + + let dir = folder; + while (dir !== path.dirname(dir)) { + if (hasL10nYaml(dir)) { + return path.join(dir, "l10n.yaml"); + } else if (hasPubspec(dir) || hasPackageMapFile(dir)) { + return undefined; + } + + dir = path.dirname(dir); + } + + return undefined; +} + +export function getL10nYamlContent(l10nYamlPath: string | undefined): string | undefined { + return getCachedL10nYaml(l10nYamlPath)?.content; +} + +export function getParsedL10nYaml(l10nYamlPath: string | undefined): T | undefined { + return getCachedL10nYaml(l10nYamlPath)?.parsed as T | undefined; +} + +function getCachedL10nYaml(l10nYamlPath: string | undefined): L10nYamlCacheEntry | undefined { + if (!l10nYamlPath || !fs.existsSync(l10nYamlPath)) return undefined; + + const stat = fs.statSync(l10nYamlPath); + const cached = l10nYamlCache.get(l10nYamlPath); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached; + + const content = fs.readFileSync(l10nYamlPath, "utf8"); + let parsed: Record | undefined; + try { + const yaml = YAML.parse(content) as unknown; + parsed = yaml && typeof yaml === "object" + ? yaml as Record + : {}; + } catch { + parsed = undefined; + } - let dir = folder; - while (dir !== path.dirname(dir)) { - if (hasL10nYaml(dir)) { - return path.join(dir, "l10n.yaml"); - } else if (hasPubspec(dir) || hasPackageMapFile(dir)) { - return undefined; - } - dir = path.dirname(dir); - } + const entry: L10nYamlCacheEntry = { + mtimeMs: stat.mtimeMs, + content, + parsed, + }; + l10nYamlCache.set(l10nYamlPath, entry); - return undefined; + return entry; } function hasPackageMapFile(folder: string): boolean { - return fs.existsSync(path.join(folder, ".dart_tool", "package_config.json")) || fs.existsSync(path.join(folder, ".packages")); + return fs.existsSync(path.join(folder, ".dart_tool", "package_config.json")) || fs.existsSync(path.join(folder, ".packages")); } function hasPubspec(folder: string): boolean { - return fs.existsSync(path.join(folder, "pubspec.yaml")); + return fs.existsSync(path.join(folder, "pubspec.yaml")); } function hasL10nYaml(folder: string): boolean { - return fs.existsSync(path.join(folder, "l10n.yaml")); + return fs.existsSync(path.join(folder, "l10n.yaml")); } function isWithinWorkspace(file: string) { - return !!workspace.getWorkspaceFolder(Uri.file(file)); + return !!workspace.getWorkspaceFolder(Uri.file(file)); } diff --git a/src/test/suite/codelens.test.ts b/src/test/suite/codelens.test.ts index de17518..88c9cf2 100644 --- a/src/test/suite/codelens.test.ts +++ b/src/test/suite/codelens.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. import * as assert from 'assert'; -import { getMemberAccessCandidates } from '../../codelens'; +import { extractLanguageFromL10nYamlContent, getMemberAccessCandidates, resolveDisplayLanguage } from '../../codelens'; suite('AppLocalizations CodeLens', () => { test('does not match AppLocalizations.of in assignment', () => { @@ -154,4 +154,43 @@ AppLocalizations.of(context)?.abc; ['AppLocalizations.abc'] ); }); + + test('resolves language from template-arb-file in l10n.yaml', () => { + const content = ` +arb-dir: lib/l10n +template-arb-file: app_ja.arb +`; + + assert.strictEqual(extractLanguageFromL10nYamlContent(content), 'ja'); + }); + + test('resolves custom language when mode is custom', () => { + const resolved = resolveDisplayLanguage({ + languageMode: 'custom', + customLanguage: 'es', + l10nYamlContent: 'template-arb-file: app_en.arb', + }); + + assert.strictEqual(resolved, 'es'); + }); + + test('falls back to l10n.yaml language when custom language is empty', () => { + const resolved = resolveDisplayLanguage({ + languageMode: 'custom', + customLanguage: ' ', + l10nYamlContent: 'template-arb-file: app_zh.arb', + }); + + assert.strictEqual(resolved, 'zh'); + }); + + test('defaults to en when no settings or l10n language available', () => { + const resolved = resolveDisplayLanguage({ + languageMode: 'definedByYaml', + customLanguage: '', + l10nYamlContent: 'arb-dir: lib/l10n', + }); + + assert.strictEqual(resolved, 'en'); + }); }); From 0f4a3fa3706b64eec53ef155d3b32cfb7b62191f Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 01:04:14 +0300 Subject: [PATCH 7/8] feat: display actual value from the `.arb` file --- src/codelens.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++---- src/project.ts | 37 +++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/codelens.ts b/src/codelens.ts index e2d0096..abfc4c7 100644 --- a/src/codelens.ts +++ b/src/codelens.ts @@ -14,7 +14,7 @@ import * as vscode from 'vscode'; import path = require('path'); import YAML = require('yaml'); -import { getL10nYamlContent, locateL10nYaml } from './project'; +import { getArbMessages, getL10nYamlContent, getParsedL10nYaml, locateL10nYaml } from './project'; /** * Pattern to find member access expressions: @@ -36,6 +36,10 @@ type MemberAccessCandidate = { }; type CodeLensLanguageMode = 'definedByYaml' | 'custom'; +type L10nYamlOptions = { + 'arb-dir'?: string; + 'template-arb-file'?: string; +}; /** * CodeLens provider class. @@ -88,6 +92,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider const source = document.getText(); const candidates = getMemberAccessCandidates(source); const displayLanguage = resolveCodeLensDisplayLanguage(document); + const localizedMessages = resolveLocalizedArbMessages(document, displayLanguage); const codeLenses: vscode.CodeLens[] = []; for (const candidate of candidates) { @@ -97,12 +102,15 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider const position = document.positionAt(offset); if (!candidate.requiresHoverCheck) { + const title = getCodeLensTitle(candidate.member, displayLanguage, localizedMessages); + if (!title) continue; + const lensPosition = document.positionAt(offset); const range = new vscode.Range(lensPosition, lensPosition); codeLenses.push( new vscode.CodeLens(range, { - title: getCodeLensTitle(displayLanguage), + title, command: 'arb-editor.noopCodeLens' }) ); @@ -118,12 +126,15 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider ); if (hovers && hovers.length > 0 && isAppLocalizationsType(hovers[0])) { + const title = getCodeLensTitle(candidate.member, displayLanguage, localizedMessages); + if (!title) continue; + const lensPosition = document.positionAt(offset); const range = new vscode.Range(lensPosition, lensPosition); codeLenses.push( new vscode.CodeLens(range, { - title: getCodeLensTitle(displayLanguage), + title, command: 'arb-editor.noopCodeLens' }) ); @@ -137,8 +148,66 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider } } -function getCodeLensTitle(language: string): string { - return `Ah-ha! AppLocalizations is below me :) [${language}]`; +function getCodeLensTitle(member: string, language: string, localizedMessages: Record | undefined): string | undefined { + const message = localizedMessages?.[member]; + if (!message) return undefined; + + const normalized = message.replace(/\s+/g, ' ').trim(); + const preview = normalized.length > 80 + ? `${normalized.slice(0, 77)}...` + : normalized; + + return `[${language}]: ${preview}`; +} + +function resolveLocalizedArbMessages(document: vscode.TextDocument, language: string): Record | undefined { + const l10nYamlPath = locateL10nYaml(path.dirname(document.uri.fsPath)); + if (!l10nYamlPath) { + return undefined; + } + + const l10nOptions = getParsedL10nYaml(l10nYamlPath); + const arbPath = resolveArbPathForLanguage(l10nYamlPath, language, l10nOptions); + return getArbMessages(arbPath); +} + +function resolveArbPathForLanguage( + l10nYamlPath: string, + language: string, + l10nOptions: L10nYamlOptions | undefined, +): string | undefined { + const baseDir = path.dirname(l10nYamlPath); + const arbDirOption = l10nOptions?.['arb-dir'] ?? 'lib/l10n'; + const templateArbFile = l10nOptions?.['template-arb-file'] ?? 'app_en.arb'; + const arbDir = path.isAbsolute(arbDirOption) + ? arbDirOption + : path.join(baseDir, arbDirOption); + + const candidateFiles = buildArbFileCandidates(templateArbFile, language); + for (const candidateFile of candidateFiles) { + const arbPath = path.isAbsolute(candidateFile) + ? candidateFile + : path.join(arbDir, candidateFile); + const messages = getArbMessages(arbPath); + if (messages) { + return arbPath; + } + } + + return undefined; +} + +function buildArbFileCandidates(templateArbFile: string, language: string): string[] { + const candidates = new Set(); + const normalizedLanguage = language.trim(); + if (normalizedLanguage) { + candidates.add(templateArbFile.replace(/_([A-Za-z0-9_-]+)\.arb$/, `_${normalizedLanguage}.arb`)); + candidates.add(`app_${normalizedLanguage}.arb`); + candidates.add(`${normalizedLanguage}.arb`); + } + candidates.add(templateArbFile); + + return [...candidates]; } /** diff --git a/src/project.ts b/src/project.ts index 4198c58..d358cf2 100644 --- a/src/project.ts +++ b/src/project.ts @@ -11,7 +11,13 @@ type L10nYamlCacheEntry = { parsed: Record | undefined; }; +type ArbMessagesCacheEntry = { + mtimeMs: number; + messages: Record | undefined; +}; + const l10nYamlCache = new Map(); +const arbMessagesCache = new Map(); export function locateL10nYaml(folder: string): string | undefined { if (!folder || (!isWithinWorkspace(folder) && workspace.workspaceFolders?.length)) return undefined; @@ -38,6 +44,37 @@ export function getParsedL10nYaml(l10nYamlPath: string | undefined): T | unde return getCachedL10nYaml(l10nYamlPath)?.parsed as T | undefined; } +export function getArbMessages(arbPath: string | undefined): Record | undefined { + if (!arbPath || !fs.existsSync(arbPath)) return undefined; + + const stat = fs.statSync(arbPath); + const cached = arbMessagesCache.get(arbPath); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached.messages; + + const content = fs.readFileSync(arbPath, "utf8"); + let messages: Record | undefined; + try { + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === "object") { + messages = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (!key.startsWith('@') && typeof value === "string") { + messages[key] = value; + } + } + } + } catch { + messages = undefined; + } + + arbMessagesCache.set(arbPath, { + mtimeMs: stat.mtimeMs, + messages, + }); + + return messages; +} + function getCachedL10nYaml(l10nYamlPath: string | undefined): L10nYamlCacheEntry | undefined { if (!l10nYamlPath || !fs.existsSync(l10nYamlPath)) return undefined; From 3190ca2fcac7201b4d71c73db7ec253433b4ead8 Mon Sep 17 00:00:00 2001 From: Zensonaton Date: Wed, 22 Apr 2026 15:24:16 +0300 Subject: [PATCH 8/8] feat: templates for code lens text --- package.json | 11 +++++-- src/codelens.ts | 58 ++++++++++++++++++++++++++------- src/test/suite/codelens.test.ts | 24 +++++++++++++- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 481c580..bea69ad 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "arb-editor.enableAppLocalizationsCodeLens": { "type": "boolean", "default": true, - "description": "Enable CodeLens for AppLocalizations member access in Dart files." + "markdownDescription": "Enable AppLocalizations CodeLens in Dart files." }, "arb-editor.appLocalizationsCodeLensLanguageMode": { "type": "string", @@ -93,12 +93,17 @@ "custom" ], "default": "definedByYaml", - "markdownDescription": "Controls language source for CodeLens text preview.\nUse `#arb-editor.appLocalizationsCodeLensCustomLanguage#` to set the custom language value." + "markdownDescription": "Selects how the `lang` template variable is resolved for AppLocalizations CodeLens text.\n\n- `definedByYaml`: uses language defined in the `template-arb-file` value of `l10n.yaml` file.\n- `custom`: uses `#arb-editor.appLocalizationsCodeLensCustomLanguage#`." }, "arb-editor.appLocalizationsCodeLensCustomLanguage": { "type": "string", "default": "en", - "markdownDescription": "Custom language code for CodeLens text (for example: `en`, `ru`, `fr`).\n\nThis field only works when `#arb-editor.appLocalizationsCodeLensLanguageMode#` is set to `custom`." + "markdownDescription": "Custom language code used for the `lang` template variable (for example: `en`, `fr`, `ru`).\n\nThis setting only applies when `#arb-editor.appLocalizationsCodeLensLanguageMode#` is `custom`." + }, + "arb-editor.appLocalizationsCodeLensTemplate": { + "type": "string", + "default": "[${lang}]: \"${value}\"", + "markdownDescription": "Template used to render AppLocalizations CodeLens text.\n\nAvailable variables:\n- `${value}`: localized value from the resolved `.arb` file.\n- `${path}`: full path to the resolved `.arb` file.\n- `${filename}`: filename of the resolved `.arb` file.\n- `${lang}`: resolved language code (`en`, `fr`, `ru`)." } } } diff --git a/src/codelens.ts b/src/codelens.ts index abfc4c7..f23dbe4 100644 --- a/src/codelens.ts +++ b/src/codelens.ts @@ -40,6 +40,11 @@ type L10nYamlOptions = { 'arb-dir'?: string; 'template-arb-file'?: string; }; +type LocalizedArbData = { + path: string; + filename: string; + messages: Record; +}; /** * CodeLens provider class. @@ -57,6 +62,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider !event.affectsConfiguration('arb-editor.enableAppLocalizationsCodeLens') && !event.affectsConfiguration('arb-editor.appLocalizationsCodeLensLanguageMode') && !event.affectsConfiguration('arb-editor.appLocalizationsCodeLensCustomLanguage') + && !event.affectsConfiguration('arb-editor.appLocalizationsCodeLensTemplate') ) { return; } @@ -91,8 +97,10 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider ): Promise { const source = document.getText(); const candidates = getMemberAccessCandidates(source); + const config = vscode.workspace.getConfiguration('arb-editor'); + const template = config.get('appLocalizationsCodeLensTemplate', '[${lang}] ${value}'); const displayLanguage = resolveCodeLensDisplayLanguage(document); - const localizedMessages = resolveLocalizedArbMessages(document, displayLanguage); + const localizedArbData = resolveLocalizedArbData(document, displayLanguage); const codeLenses: vscode.CodeLens[] = []; for (const candidate of candidates) { @@ -102,7 +110,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider const position = document.positionAt(offset); if (!candidate.requiresHoverCheck) { - const title = getCodeLensTitle(candidate.member, displayLanguage, localizedMessages); + const title = getCodeLensTitle(candidate.member, displayLanguage, template, localizedArbData); if (!title) continue; const lensPosition = document.positionAt(offset); @@ -126,7 +134,7 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider ); if (hovers && hovers.length > 0 && isAppLocalizationsType(hovers[0])) { - const title = getCodeLensTitle(candidate.member, displayLanguage, localizedMessages); + const title = getCodeLensTitle(candidate.member, displayLanguage, template, localizedArbData); if (!title) continue; const lensPosition = document.positionAt(offset); @@ -148,19 +156,38 @@ export class AppLocalizationsCodeLensProvider implements vscode.CodeLensProvider } } -function getCodeLensTitle(member: string, language: string, localizedMessages: Record | undefined): string | undefined { - const message = localizedMessages?.[member]; +function getCodeLensTitle( + member: string, + language: string, + template: string, + localizedArbData: LocalizedArbData | undefined, +): string | undefined { + const message = localizedArbData?.messages[member]; if (!message) return undefined; const normalized = message.replace(/\s+/g, ' ').trim(); - const preview = normalized.length > 80 - ? `${normalized.slice(0, 77)}...` - : normalized; + const rendered = renderCodeLensTemplate(template, { + value: normalized, + path: localizedArbData.path, + filename: localizedArbData.filename, + lang: language, + }).trim(); + + return rendered || undefined; +} - return `[${language}]: ${preview}`; +export function renderCodeLensTemplate( + template: string, + variables: { value: string; path: string; filename: string; lang: string; }, +): string { + return template + .replace(/\$\{value\}/g, variables.value) + .replace(/\$\{path\}/g, variables.path) + .replace(/\$\{filename\}/g, variables.filename) + .replace(/\$\{lang\}/g, variables.lang); } -function resolveLocalizedArbMessages(document: vscode.TextDocument, language: string): Record | undefined { +function resolveLocalizedArbData(document: vscode.TextDocument, language: string): LocalizedArbData | undefined { const l10nYamlPath = locateL10nYaml(path.dirname(document.uri.fsPath)); if (!l10nYamlPath) { return undefined; @@ -168,7 +195,16 @@ function resolveLocalizedArbMessages(document: vscode.TextDocument, language: st const l10nOptions = getParsedL10nYaml(l10nYamlPath); const arbPath = resolveArbPathForLanguage(l10nYamlPath, language, l10nOptions); - return getArbMessages(arbPath); + const messages = getArbMessages(arbPath); + if (!arbPath || !messages) { + return undefined; + } + + return { + path: arbPath, + filename: path.basename(arbPath), + messages, + }; } function resolveArbPathForLanguage( diff --git a/src/test/suite/codelens.test.ts b/src/test/suite/codelens.test.ts index 88c9cf2..67490de 100644 --- a/src/test/suite/codelens.test.ts +++ b/src/test/suite/codelens.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. import * as assert from 'assert'; -import { extractLanguageFromL10nYamlContent, getMemberAccessCandidates, resolveDisplayLanguage } from '../../codelens'; +import { extractLanguageFromL10nYamlContent, getMemberAccessCandidates, renderCodeLensTemplate, resolveDisplayLanguage } from '../../codelens'; suite('AppLocalizations CodeLens', () => { test('does not match AppLocalizations.of in assignment', () => { @@ -193,4 +193,26 @@ template-arb-file: app_ja.arb assert.strictEqual(resolved, 'en'); }); + + test('renders codelens template with all supported variables', () => { + const rendered = renderCodeLensTemplate('[${lang}] ${filename} ${value} (${path})', { + value: 'Hello world', + path: '/tmp/app_en.arb', + filename: 'app_en.arb', + lang: 'en', + }); + + assert.strictEqual(rendered, '[en] app_en.arb Hello world (/tmp/app_en.arb)'); + }); + + test('renders codelens template using dollar-brace placeholders', () => { + const rendered = renderCodeLensTemplate('[${lang}] ${value}', { + value: 'Bonjour', + path: '/tmp/app_fr.arb', + filename: 'app_fr.arb', + lang: 'fr', + }); + + assert.strictEqual(rendered, '[fr] Bonjour'); + }); });