From 91003da8b1ad95546a3b5420a6dba1c935351aab Mon Sep 17 00:00:00 2001 From: y-ok Date: Sun, 29 Mar 2026 22:16:47 +0900 Subject: [PATCH] Release 1.0.17 --- CHANGELOG.md | 22 ++ package-lock.json | 4 +- package.json | 2 +- src/CompletionProvider.ts | 14 +- src/DefinitionProvider.ts | 10 +- src/HoverProvider.ts | 20 +- src/PropertiesQuickFixProvider.ts | 10 +- src/PropertyValidator.ts | 9 +- src/diagnostic.ts | 373 +++++++++++++++++++++----- src/extension.ts | 100 ++++++- src/outputChannel.ts | 9 +- src/utils.ts | 88 ++++--- test/diagnostic.test.ts | 378 ++++++++++++++++++++++++++- test/extension.cacheIndexing.test.ts | 22 ++ 14 files changed, 919 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db11c3..9e3cbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [1.0.17] - 2026-03-29 + +### Fixed (1.0.17) + +- Fixed false-positive placeholder diagnostics for pure varargs calls with a + trailing identifier by resolving only trailing excess arguments through type + definitions and ignoring them only when they resolve to throwable types. +- Fixed placeholder validation so unresolved or non-throwable trailing + arguments stay on the safe side and continue to produce mismatch diagnostics + instead of being silently discarded. + +### Changed (1.0.17) + +- Bumped extension version to **1.0.17**. +- Added JSDoc comments across all files under `src/` and translated remaining + source-code comments to English. +- Expanded regression coverage for throwable argument resolution, validation + edge cases, and watcher/configuration branches. The test suite now maintains + **100%** statement, branch, function, and line coverage. + +--- + ## [1.0.16] - 2026-03-28 ### Fixed (1.0.16) diff --git a/package-lock.json b/package-lock.json index 2e78ec7..931a9d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-message-key-navigator", - "version": "1.0.14", + "version": "1.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-message-key-navigator", - "version": "1.0.14", + "version": "1.0.17", "devDependencies": { "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index 81de31a..47040f0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Java Message Key Navigator", "description": "A VS Code extension to assist with Java I18N (internationalization) message management", "publisher": "y-ok", - "version": "1.0.16", + "version": "1.0.17", "icon": "icon.png", "engines": { "vscode": "^1.85.0" diff --git a/src/CompletionProvider.ts b/src/CompletionProvider.ts index 0bb602b..368f4d9 100644 --- a/src/CompletionProvider.ts +++ b/src/CompletionProvider.ts @@ -1,9 +1,16 @@ import * as vscode from "vscode"; import { getAllPropertyKeys, getPropertyValue } from "./utils"; +/** + * Provides completion candidates for message keys while editing supported Java + * method calls and annotations. + */ export class MessageKeyCompletionProvider implements vscode.CompletionItemProvider { + /** + * Returns completion items that match the partially typed message key. + */ async provideCompletionItems( document: vscode.TextDocument, position: vscode.Position @@ -24,13 +31,13 @@ export class MessageKeyCompletionProvider const lineText = document.lineAt(position).text; - // 行にパターンを含むかだけ判定する + // Only check whether the current line contains one of the configured patterns. const matchesPattern = patterns.some((pattern) => lineText.includes(pattern) ); if (!matchesPattern) {return undefined;} - // 入力中の文字を取得 + // Extract the partial key being typed inside the current string literal. const lineUntilPosition = document .lineAt(position) .text.substring(0, position.character); @@ -41,6 +48,9 @@ export class MessageKeyCompletionProvider } } +/** + * Builds completion items enriched with the corresponding property value. + */ function generateCompletionItems(input: string): vscode.CompletionItem[] { const keys = getAllPropertyKeys(); const filteredKeys = input diff --git a/src/DefinitionProvider.ts b/src/DefinitionProvider.ts index 99b0235..a8cd181 100644 --- a/src/DefinitionProvider.ts +++ b/src/DefinitionProvider.ts @@ -6,14 +6,20 @@ import { findPropertyLocation, } from "./utils"; +/** + * Resolves a message key reference to the matching entry in a properties file. + */ export class PropertiesDefinitionProvider implements vscode.DefinitionProvider { + /** + * Returns the definition location for the message key under the cursor. + */ public async provideDefinition( document: vscode.TextDocument, position: vscode.Position ): Promise { outputChannel.appendLine("🔍 Executing DefinitionProvider..."); - // propertyFileGlobs を読み込んでキャッシュを更新 + // Refresh the cache using the configured propertyFileGlobs. const customProps = vscode.workspace .getConfiguration("java-message-key-navigator") .get("propertyFileGlobs", []); @@ -36,7 +42,7 @@ export class PropertiesDefinitionProvider implements vscode.DefinitionProvider { outputChannel.appendLine(`✅ Jump target key: ${key}`); - // ← ここを await で呼び出す + // Resolve the definition target before constructing the VS Code location. const loc = await findPropertyLocation(key); if (loc) { outputChannel.appendLine( diff --git a/src/HoverProvider.ts b/src/HoverProvider.ts index e6c289d..13af03f 100644 --- a/src/HoverProvider.ts +++ b/src/HoverProvider.ts @@ -2,7 +2,13 @@ import * as vscode from "vscode"; import { getCustomPatterns, getPropertyValue } from "./utils"; import { outputChannel } from "./outputChannel"; +/** + * Shows the resolved property value when hovering a supported message key. + */ export class PropertiesHoverProvider implements vscode.HoverProvider { + /** + * Returns hover content for the message key under the cursor. + */ public provideHover( document: vscode.TextDocument, position: vscode.Position @@ -10,7 +16,7 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { const text = document.getText(); const offset = document.offsetAt(position); - // カスタムパターンを取得 (log("KEY") や @LogStartEnd(...) など) + // Collect configured extraction patterns such as method calls and annotations. const patterns = getCustomPatterns(); const processedKeys = new Set(); @@ -18,9 +24,9 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { regex.lastIndex = 0; let match: RegExpExecArray | null; - // ドキュメント全体をパターンマッチ + // Match the configured pattern across the whole document. while ((match = regex.exec(text)) !== null) { - // マッチしたキャプチャグループ (match[1], match[2], …) をすべてキーとして扱う + // Treat every captured group as a possible message key. const keys = match .slice(1) .filter((g): g is string => typeof g === "string"); @@ -28,11 +34,11 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { for (const key of keys) { if (!key || processedKeys.has(key)) {continue;} - // キャプチャ文字列の開始・終了オフセットを計算 + // Compute the captured key range inside the document text. const start = match.index + match[0].indexOf(key); const end = start + key.length; - // カーソル位置がその範囲内ならホバーを返す + // Return hover content only when the cursor is inside the key range. if (offset >= start && offset <= end) { processedKeys.add(key); outputChannel.appendLine( @@ -41,7 +47,7 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { let value = getPropertyValue(key); if (value) { - // メッセージ中に "=" が含まれる場合はコードブロックで囲む + // Render multi-part values in a code block when they contain "=". if (value.includes("=")) { value = "```\n" + value + "\n```"; } @@ -55,7 +61,7 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { } } - // マッチしなければ何も返さない + // Return nothing when the cursor is not on a supported key reference. return; } } diff --git a/src/PropertiesQuickFixProvider.ts b/src/PropertiesQuickFixProvider.ts index 8fbc4c0..3632831 100644 --- a/src/PropertiesQuickFixProvider.ts +++ b/src/PropertiesQuickFixProvider.ts @@ -1,11 +1,17 @@ import * as vscode from "vscode"; import { outputChannel } from "./outputChannel"; +/** + * Offers quick fixes for diagnostics related to missing properties entries. + */ export class PropertiesQuickFixProvider implements vscode.CodeActionProvider { public static readonly providedCodeActionKinds = [ vscode.CodeActionKind.QuickFix, ]; + /** + * Creates a quick fix that inserts the missing key into a properties file. + */ public async provideCodeActions( document: vscode.TextDocument, range: vscode.Range, @@ -18,11 +24,11 @@ export class PropertiesQuickFixProvider implements vscode.CodeActionProvider { ); if (diagnostics.length === 0) {return [];} - // ① 診断からキー文字列を抜き取り + // Extract the missing key from the diagnostic range. const key = document.getText(range).replace(/["']/g, "").trim(); outputChannel.appendLine(`🔍 Undefined key: ${key}`); - // ② アクションを生成(ファイル選択はコマンドハンドラ側の showQuickPick で行う) + // Build the quick fix; the command handler performs file selection. const title = `💾 Add "${key}" to properties file`; const action = new vscode.CodeAction(title, vscode.CodeActionKind.QuickFix); action.diagnostics = diagnostics; diff --git a/src/PropertyValidator.ts b/src/PropertyValidator.ts index c8a9074..cfbb975 100644 --- a/src/PropertyValidator.ts +++ b/src/PropertyValidator.ts @@ -7,6 +7,10 @@ import { } from "./utils"; import { outputChannel } from "./outputChannel"; +/** + * Validates that every extracted message key is defined in the configured + * properties files. + */ export async function validateProperties( document: vscode.TextDocument, diagnostics: vscode.DiagnosticCollection, @@ -53,6 +57,9 @@ export async function validateProperties( ); } +/** + * Validates placeholder indices inside a single message definition. + */ export function validateMessagePlaceholders( key: string, value: string, @@ -72,7 +79,7 @@ export function validateMessagePlaceholders( const indices = Array.from(found).sort((a, b) => a - b); - // チェック条件: {0} が含まれているか & 連番になっているか + // Require placeholders to start at {0} and increase without gaps. if (indices[0] !== 0 || !indices.every((v, i) => v === i)) { return { message: `メッセージ内のプレースホルダー {n} は {0} から始まり連番である必要がありますが、不正な順番です: {${indices.join( diff --git a/src/diagnostic.ts b/src/diagnostic.ts index 5a052ca..1c95941 100644 --- a/src/diagnostic.ts +++ b/src/diagnostic.ts @@ -1,11 +1,25 @@ import * as vscode from "vscode"; import { getMessageValueForKey } from "./utils"; +/** + * Describes a configured helper that expands to a fixed number of arguments. + */ interface ArgBuilderPattern { pattern: string; argCount: number; } +/** + * Represents a split argument together with its start offset inside the call. + */ +interface ArgPart { + text: string; + start: number; +} + +/** + * Validates placeholder usage for supported Java message-key invocations. + */ export async function validatePlaceholders( document: vscode.TextDocument, collection: vscode.DiagnosticCollection @@ -30,8 +44,8 @@ export async function validatePlaceholders( regex.lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { - // 1. カッコ内全体を抜き出す - const callStart = regex.lastIndex - 1; // "("の位置 + // 1. Extract the entire argument list enclosed by the call parentheses. + const callStart = regex.lastIndex - 1; // Position of the opening parenthesis. let callEnd = callStart, depth = 0; for (let i = callStart; i < text.length; i++) { @@ -44,10 +58,10 @@ export async function validatePlaceholders( } const argString = text.slice(callStart + 1, callEnd); - // 2. safeSplit で分割(1個目はkey, 2個目以降が引数) - const parts = safeSplit(argString); + // 2. Split arguments safely; the first entry is the key and the rest are values. + const parts = safeSplitWithOffsets(argString); if (parts.length === 0) {continue;} - const firstArg = parts[0].trim(); + const firstArg = parts[0].text.trim(); const keyMatch = firstArg.match(/^(['"])([\s\S]*)\1$/); if (!keyMatch) {continue;} @@ -65,14 +79,14 @@ export async function validatePlaceholders( continue; } - // 3. プレースホルダー数を算出 + // 3. Resolve the message and derive the expected placeholder count. const messageValue = await getMessageValueForKey(key); if (!messageValue) {continue;} const placeholders = Array.from( messageValue.matchAll(/(? Number(m[1])); - // プレースホルダーの正当性チェック: {0} がない、不連続 など + // Validate placeholder ordering, for example missing {0} or skipped indices. if (placeholders.length > 0) { const sorted = [...new Set(placeholders)].sort((a, b) => a - b); const isContinuous = sorted[0] === 0 && sorted.every((v, i) => v === i); @@ -94,20 +108,20 @@ export async function validatePlaceholders( const expectedArgCount = placeholders.length > 0 ? Math.max(...placeholders) + 1 : 0; - // === 例外引数除外ロジック === + // === Ignore special trailing arguments that should not count as placeholders. === if ( expectedArgCount === 0 && args.length === 1 && - isLikelyExceptionArg(args[0]) + isLikelyExceptionArg(args[0].text) ) { - // log("KEY", e) など、例外オブジェクトだけを渡すログ呼び出しは - // プレースホルダー検証の実引数としてはカウントしない + // Calls such as log("KEY", e) pass only an exception object, which should + // not be counted as a placeholder argument. args.pop(); } if (args.length > 1) { - const lastArg = args[args.length - 1].trim(); - const prevArg = args[args.length - 2].trim(); + const lastArg = args[args.length - 1].text.trim(); + const prevArg = args[args.length - 2].text.trim(); const isSingleVar = /^[A-Za-z_$][\w$]*$/.test(lastArg); const prevIsArrayLiteral = @@ -118,60 +132,37 @@ export async function validatePlaceholders( if (isSingleVar) { if (prevIsArrayLiteral) { - // 配列直後の末尾変数は常に例外扱いで除外 + // Treat a trailing identifier after an array literal as an exception argument. args.pop(); } else if (!prevIsJoinCall) { - // 純 varargs - const precedingCount = args.length - 1; // ex を除いた個数 - // 2パターンで除外する: - // 1) 先行が2個以上("A","B",ex など)…常に除外(ex を例外として扱う) - // 2) 先行が1個 かつ プレースホルダーが1個("A",ex × "Hi {0}")…一致させるために除外 + // For plain varargs calls, only ignore the trailing argument when its + // resolved type is throwable-like. + const precedingCount = args.length - 1; if ( precedingCount >= 2 || (precedingCount === 1 && expectedArgCount === 1) ) { - args.pop(); + const shouldIgnore = await isThrowableArgument( + document, + args[args.length - 1], + callStart + 1 + ); + if (shouldIgnore) { + args.pop(); + } } - // ※ prev が join() のときはこの分岐に入らないので除外しない } - // varargs の直前が join() のとき(join(), ex)は除外しない } } - // 4. 引数カウント - let actualArgCount = 0; - if (args.length === 0 || (args.length === 1 && args[0].trim() === "")) { - actualArgCount = 0; - } else if (args.length === 1) { - // 配列リテラル or join特別扱い - const arrayMatch = args[0].match( - /new\s+[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\[\s*\]\s*{([\s\S]*?)}/ - ); - if (arrayMatch) { - const arr = safeSplit(arrayMatch[1]); - if (arr.length === 1 && /\.join\s*\(/.test(arr[0])) { - actualArgCount = expectedArgCount; - } else { - actualArgCount = arr.filter((e) => e.trim() !== "").length; - } - } else if (/\.join\s*\(/.test(args[0])) { - actualArgCount = expectedArgCount; - } else { - const builderArgCount = matchArgBuilderPattern( - args[0], - argBuilderPatterns - ); - if (builderArgCount !== null) { - actualArgCount = builderArgCount; - } else { - actualArgCount = 1; - } - } - } else { - actualArgCount = args.filter((e) => e.trim() !== "").length; - } + // 4. Count the effective arguments after applying supported shortcuts. + const actualArgCount = countActualArguments( + args, + expectedArgCount, + argBuilderPatterns + ); - // 5. 診断 + // 5. Emit a diagnostic when the placeholder count does not match. if ( (expectedArgCount === 0 && actualArgCount > 0) || (expectedArgCount > 0 && actualArgCount !== expectedArgCount) @@ -191,6 +182,9 @@ export async function validatePlaceholders( collection.set(document.uri, diagnostics); } +/** + * Adds a diagnostic only once for the same location and message pair. + */ function pushDiagnosticOnce( diagnostics: vscode.Diagnostic[], seenDiagnostics: Set, @@ -206,20 +200,29 @@ function pushDiagnosticOnce( diagnostics.push(diagnostic); } +/** + * Normalizes a configured method pattern so it can be converted to a call regex. + */ function normalizeMethodPattern(pattern: string): string { return pattern.trim().replace(/\(\s*$/, ""); } +/** + * Escapes regular-expression metacharacters in literal method names. + */ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function stripTrailingLocaleArg(args: string[]): boolean { +/** + * Removes a trailing locale argument from the parsed argument list. + */ +function stripTrailingLocaleArg(args: ArgPart[]): boolean { if (args.length === 0) { return false; } - const lastArg = args[args.length - 1]; + const lastArg = args[args.length - 1].text; if (!isLikelyLocaleArg(lastArg)) { return false; } @@ -228,6 +231,10 @@ function stripTrailingLocaleArg(args: string[]): boolean { return true; } +/** + * Heuristically detects locale-like expressions that should not count as + * message placeholders. + */ function isLikelyLocaleArg(arg: string): boolean { const trimmed = arg.trim(); @@ -238,34 +245,47 @@ function isLikelyLocaleArg(arg: string): boolean { ); } +/** + * Heuristically detects exception-like identifiers used without type + * information. + */ function isLikelyExceptionArg(arg: string): boolean { const trimmed = arg.trim(); const isIdentifier = /^[A-Za-z_$][\w$]*$/.test(trimmed); if (!isIdentifier) {return false;} - // catch (Exception e) / catch (...) ex などの短い慣用名 if (/^(e|ex|err|error|exception|throwable|cause)$/i.test(trimmed)) { return true; } - // exceptionObj / dbException / rootCause / throwableError などを許可 return /(exception|throwable|cause|error)/i.test(trimmed); } /** - * カンマ分割(文字列リテラル・括弧対応) + * Splits a raw argument list while respecting nested syntax and string literals. */ function safeSplit(argString: string): string[] { - const result: string[] = []; + return safeSplitWithOffsets(argString).map((part) => part.text); +} + +/** + * Splits a raw argument list and preserves each part's relative offset. + */ +function safeSplitWithOffsets(argString: string): ArgPart[] { + const result: ArgPart[] = []; let buffer = ""; let inQuotes = false; let quoteChar = ""; let parenDepth = 0; // () let braceDepth = 0; // {} let bracketDepth = 0; // [] + let tokenStart = -1; for (let i = 0; i < argString.length; i++) { const ch = argString[i]; + if (tokenStart === -1 && /\S/.test(ch)) { + tokenStart = i; + } if (inQuotes) { buffer += ch; @@ -314,28 +334,45 @@ function safeSplit(argString: string): string[] { continue; } - // トップレベルのカンマのみで分割 + // Split only on top-level commas. if ( ch === "," && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0 ) { - result.push(buffer.trim()); + pushArgPart(result, buffer, tokenStart); buffer = ""; + tokenStart = -1; continue; } buffer += ch; } - if (buffer.trim() !== "") {result.push(buffer.trim());} + pushArgPart(result, buffer, tokenStart); return result; } /** - * 引数式が argBuilderPatterns に定義されたメソッド呼び出しにマッチするか判定し、 - * マッチした場合は設定された argCount を返す。マッチしなければ null。 + * Pushes a trimmed argument token into the parsed result list. + */ +function pushArgPart(result: ArgPart[], raw: string, tokenStart: number): void { + const trimmed = raw.trim(); + if (trimmed === "") { + return; + } + + const leadingWhitespace = raw.length - raw.trimStart().length; + result.push({ + text: trimmed, + start: tokenStart, + }); +} + +/** + * Matches an argument-builder helper and returns the number of arguments it + * contributes. */ function matchArgBuilderPattern( argText: string, @@ -344,7 +381,7 @@ function matchArgBuilderPattern( const trimmed = argText.trim(); for (const { pattern, argCount } of patterns) { const esc = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // buildArgs(...), Utils.buildArgs(...), this.buildArgs(...) にマッチ + // Match buildArgs(...), Utils.buildArgs(...), or this.buildArgs(...). const re = new RegExp(`(?:^|\\.)${esc}\\s*\\(`); if (re.test(trimmed)) { return argCount; @@ -352,3 +389,205 @@ function matchArgBuilderPattern( } return null; } + +/** + * Counts effective placeholder arguments after applying supported shortcuts. + */ +function countActualArguments( + args: ArgPart[], + expectedArgCount: number, + argBuilderPatterns: ArgBuilderPattern[] +): number { + const argTexts = args.map((arg) => arg.text); + if ( + argTexts.length === 0 || + (argTexts.length === 1 && argTexts[0].trim() === "") + ) { + return 0; + } + + if (argTexts.length === 1) { + const arrayMatch = argTexts[0].match( + /new\s+[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\[\s*\]\s*{([\s\S]*?)}/ + ); + if (arrayMatch) { + const arr = safeSplit(arrayMatch[1]); + if (arr.length === 1 && /\.join\s*\(/.test(arr[0])) { + return expectedArgCount; + } + return arr.filter((e) => e.trim() !== "").length; + } + + if (/\.join\s*\(/.test(argTexts[0])) { + return expectedArgCount; + } + + const builderArgCount = matchArgBuilderPattern( + argTexts[0], + argBuilderPatterns + ); + if (builderArgCount !== null) { + return builderArgCount; + } + + return 1; + } + + return argTexts.filter((e) => e.trim() !== "").length; +} + +/** + * Resolves the trailing argument type and returns whether it is throwable-like. + */ +async function isThrowableArgument( + document: vscode.TextDocument, + arg: ArgPart, + baseOffset: number +): Promise { + const queryOffset = baseOffset + arg.start; + let locations: Array = []; + try { + locations = + (await vscode.commands.executeCommand< + Array + >( + "vscode.executeTypeDefinitionProvider", + document.uri, + document.positionAt(queryOffset) + )) ?? []; + } catch { + return false; + } + + for (const location of locations) { + if (await locationResolvesToThrowable(location, new Set(), 0)) { + return true; + } + } + + return false; +} + +/** + * Follows a type-definition location chain to determine whether it resolves to + * a throwable type. + */ +async function locationResolvesToThrowable( + location: vscode.Location | vscode.LocationLink, + visited: Set, + depth: number +): Promise { + if (depth > 8) { + return false; + } + + const targetUri = isLocationLink(location) ? location.targetUri : location.uri; + const targetRange = isLocationLink(location) + ? (location.targetSelectionRange ?? location.targetRange) + : location.range; + if (!targetRange) { + return false; + } + const visitKey = `${targetUri.toString()}:${targetRange.start.line}:${targetRange.start.character}`; + if (visited.has(visitKey)) { + return false; + } + visited.add(visitKey); + + let doc: vscode.TextDocument; + try { + doc = await vscode.workspace.openTextDocument(targetUri); + } catch { + return false; + } + const declaration = findTypeDeclaration(doc, targetRange.start.line); + if (!declaration) { + return false; + } + + if (isThrowableTypeName(declaration.name)) { + return true; + } + + if (!declaration.extendsName) { + return false; + } + + if (isThrowableTypeName(declaration.extendsName)) { + return true; + } + + const extendsIndex = declaration.lineText.indexOf(declaration.extendsName); + + let baseLocations: Array = []; + try { + baseLocations = + (await vscode.commands.executeCommand< + Array + >( + "vscode.executeTypeDefinitionProvider", + doc.uri, + new vscode.Position(declaration.line, extendsIndex) + )) ?? []; + } catch { + return false; + } + + for (const baseLocation of baseLocations) { + if (await locationResolvesToThrowable(baseLocation, visited, depth + 1)) { + return true; + } + } + + return false; +} + +/** + * Narrows a VS Code definition result to a {@link vscode.LocationLink}. + */ +function isLocationLink( + location: vscode.Location | vscode.LocationLink +): location is vscode.LocationLink { + return "targetUri" in location; +} + +/** + * Finds a nearby class or record declaration for a resolved Java type. + */ +function findTypeDeclaration( + document: vscode.TextDocument, + aroundLine: number +): { name: string; extendsName?: string; line: number; lineText: string } | null { + const lineCount = + typeof (document as any).lineCount === "number" + ? (document as any).lineCount + : Math.max(aroundLine + 1, 1); + const startLine = Math.max(0, aroundLine - 5); + const endLine = Math.min(lineCount - 1, aroundLine + 5); + + for (let line = startLine; line <= endLine; line++) { + const lineText = document.lineAt(line).text; + const match = lineText.match( + /\b(?:class|record)\s+([A-Za-z_$][\w$]*)(?:\s+extends\s+([A-Za-z_$][\w$.]*))?/ + ); + if (match) { + return { + name: match[1], + extendsName: match[2]?.split(".").pop(), + line, + lineText, + }; + } + } + + return null; +} + +/** + * Returns whether a simple or qualified type name is throwable-compatible. + */ +function isThrowableTypeName(typeName: string): boolean { + return /^(?:Throwable|Exception|RuntimeException|Error)$/.test( + typeName.split(".").pop()! + ); +} diff --git a/src/extension.ts b/src/extension.ts index b5a80f4..c450a2e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,12 +12,26 @@ import { } from "./utils"; import { initializeOutputChannel, outputChannel } from "./outputChannel"; +/** + * Include glob used when validating Java sources across the workspace. + */ const JAVA_INCLUDE_PATTERN = "**/src/main/java/**/*.java"; + +/** + * Exclude glob used to skip generated, build, and test sources. + */ const JAVA_EXCLUDE_PATTERN = "**/{test,tests,src/test/**,src/generated/**,build/**,out/**,target/**}/**"; +/** + * Filters hover results so excluded files never trigger provider logic. + */ class FilteredHoverProvider implements vscode.HoverProvider { constructor(private base: vscode.HoverProvider) {} + + /** + * Delegates hover resolution unless the document is excluded. + */ provideHover( document: vscode.TextDocument, position: vscode.Position, @@ -30,8 +44,15 @@ class FilteredHoverProvider implements vscode.HoverProvider { } } +/** + * Filters definition lookups so excluded files never trigger provider logic. + */ class FilteredDefinitionProvider implements vscode.DefinitionProvider { constructor(private base: vscode.DefinitionProvider) {} + + /** + * Delegates definition resolution unless the document is excluded. + */ provideDefinition( document: vscode.TextDocument, position: vscode.Position, @@ -44,8 +65,15 @@ class FilteredDefinitionProvider implements vscode.DefinitionProvider { } } +/** + * Filters quick-fix actions so excluded files never trigger provider logic. + */ class FilteredQuickFixProvider implements vscode.CodeActionProvider { constructor(private base: vscode.CodeActionProvider) {} + + /** + * Delegates code action creation unless the document is excluded. + */ provideCodeActions( document: vscode.TextDocument, range: vscode.Range | vscode.Selection, @@ -59,8 +87,15 @@ class FilteredQuickFixProvider implements vscode.CodeActionProvider { } } +/** + * Filters completion requests so excluded files never trigger provider logic. + */ class FilteredCompletionProvider implements vscode.CompletionItemProvider { constructor(private base: vscode.CompletionItemProvider) {} + + /** + * Delegates completion resolution unless the document is excluded. + */ provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, @@ -74,18 +109,30 @@ class FilteredCompletionProvider implements vscode.CompletionItemProvider { } } +/** + * Activates the extension and registers providers, commands, and validators. + */ export async function activate( context: vscode.ExtensionContext & { secrets?: any } ) { initializeOutputChannel(); + /** + * Reads the configured properties globs from extension settings. + */ const getPropertyFileGlobs = (): string[] => vscode.workspace .getConfiguration("java-message-key-navigator") .get("propertyFileGlobs", []) ?? []; + /** + * Converts a list of globs into a stable cache signature. + */ const toGlobSignature = (globs: string[]): string => globs.join("\u0000"); + /** + * Computes a compact fingerprint for a Java document's current text. + */ const computeTextFingerprint = (text: string): string => { let hash = 0; for (let i = 0; i < text.length; i++) { @@ -112,6 +159,9 @@ export async function activate( let propertyGlobSignature = toGlobSignature(initialPropertyFileGlobs); let validationQueue: Promise = Promise.resolve(); + /** + * Serializes validation work so concurrent events do not race. + */ const queueValidation = (task: () => Promise) => { validationQueue = validationQueue.then(task).catch((err) => { outputChannel.appendLine(`[Error] Validation queue failed: ${err}`); @@ -119,11 +169,17 @@ export async function activate( return validationQueue; }; + /** + * Clears both diagnostic collections for a single URI. + */ const clearDiagnosticsForUri = (uri: vscode.Uri) => { propDiagnostics.delete?.(uri); phDiagnostics.delete?.(uri); }; + /** + * Cancels any pending debounce timer for the given document URI. + */ const clearValidationTimer = (uri: vscode.Uri) => { const cacheKey = uri.fsPath; const timer = validationTimers.get(cacheKey); @@ -132,6 +188,9 @@ export async function activate( validationTimers.delete(cacheKey); }; + /** + * Validates one Java document and updates the cache if its fingerprint changed. + */ const validateJavaDocument = async ( document: vscode.TextDocument, force = false @@ -164,6 +223,9 @@ export async function activate( return true; }; + /** + * Opens and validates a Java file from its URI. + */ const validateJavaUri = async (uri: vscode.Uri, force = false) => { if (!uri.fsPath.endsWith(".java") || isExcludedFile(uri.fsPath)) { return false; @@ -177,6 +239,9 @@ export async function activate( } }; + /** + * Revalidates every in-scope Java source file in the workspace. + */ const validateWorkspaceJavaFiles = async (): Promise<{ checked: number; skipped: number; @@ -188,7 +253,7 @@ export async function activate( JAVA_EXCLUDE_PATTERN ); - // ワークスペース全体の再評価では毎回診断を作り直す。 + // Rebuild diagnostics from scratch during full-workspace validation. propDiagnostics.clear(); phDiagnostics.clear(); @@ -237,6 +302,9 @@ export async function activate( return { checked, skipped, total: found.size }; }; + /** + * Revalidates Java files already known to the cache after property changes. + */ const revalidateCachedJavaFiles = async () => { if (javaValidationCache.size === 0) { return; @@ -250,6 +318,9 @@ export async function activate( } }; + /** + * Debounces validation for a single Java document. + */ const scheduleAll = (doc: vscode.TextDocument) => { if (doc.languageId !== "java" || isExcludedFile(doc.uri.fsPath)) { return; @@ -266,17 +337,17 @@ export async function activate( }; context.subscriptions.push( - // HoverProvider + // Hover provider vscode.languages.registerHoverProvider( selector, new FilteredHoverProvider(new PropertiesHoverProvider()) ), - // DefinitionProvider + // Definition provider vscode.languages.registerDefinitionProvider( selector, new FilteredDefinitionProvider(new PropertiesDefinitionProvider()) ), - // CodeActionProvider (QuickFix) + // Code action provider (Quick Fix) vscode.languages.registerCodeActionsProvider( selector, new FilteredQuickFixProvider(new PropertiesQuickFixProvider()), @@ -285,13 +356,13 @@ export async function activate( PropertiesQuickFixProvider.providedCodeActionKinds, } ), - // CompletionItemProvider + // Completion item provider vscode.languages.registerCompletionItemProvider( selector, new FilteredCompletionProvider(new MessageKeyCompletionProvider()), '"' ), - // QuickFix コマンドハンドラ + // Quick Fix command handler vscode.commands.registerCommand( "java-message-key-navigator.addPropertyKey", async (key: string) => { @@ -340,7 +411,7 @@ export async function activate( ) ); - // === Validate all Java files in the workspace === + // Validate all Java files in the workspace. context.subscriptions.push( vscode.commands.registerCommand( "java-message-key-navigator.validateAll", @@ -355,7 +426,7 @@ export async function activate( ) ); - // バリデーション(ファイルオープン・変更・保存・エディタ切替時) + // Trigger validation on open, change, save, and active-editor switches. context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(scheduleAll), vscode.workspace.onDidChangeTextDocument((e) => scheduleAll(e.document)), @@ -384,6 +455,9 @@ export async function activate( ); } + /** + * Marks properties data dirty and schedules revalidation for cached Java files. + */ const revalidateOnPropChange = () => { propertyCacheDirty = true; void queueValidation(async () => { @@ -391,6 +465,10 @@ export async function activate( }); }; let propWatcherDisposables: vscode.Disposable[] = []; + + /** + * Recreates file watchers for every configured properties glob. + */ const createPropWatchers = (globs: string[]) => { if (typeof vscode.workspace.createFileSystemWatcher !== "function") { return; @@ -472,7 +550,7 @@ export async function activate( createPropWatchers(initialPropertyFileGlobs); } - // 初回インデックス(ワークスペースが開かれている場合) + // Warm the validation cache when a workspace is already open. if ( Array.isArray(vscode.workspace.workspaceFolders) && vscode.workspace.workspaceFolders.length > 0 @@ -483,12 +561,12 @@ export async function activate( }); } - // アクティブエディタがあれば即時バリデート + // Validate the active editor immediately when one is already open. if (vscode.window.activeTextEditor) { scheduleAll(vscode.window.activeTextEditor.document); } - // 最後に起動メッセージ + // Show the activation message last. vscode.window.showInformationMessage( "Java Message Key Navigator is now active 🚀" ); diff --git a/src/outputChannel.ts b/src/outputChannel.ts index 81ae685..6c2ba22 100644 --- a/src/outputChannel.ts +++ b/src/outputChannel.ts @@ -1,11 +1,12 @@ import * as vscode from "vscode"; -// 再代入可能に let で宣言 +/** + * Shared output channel used by the extension for debug and validation logs. + */ export let outputChannel: vscode.OutputChannel; /** - * 拡張機能が有効化されたら初回のメッセージを表示 - * すでに作られていれば clear()、作られていなければ create + * Creates or resets the extension output channel. */ export function initializeOutputChannel() { if (outputChannel) { @@ -16,7 +17,7 @@ export function initializeOutputChannel() { ); } - // タブは自動で開かない + // Keep the output tab hidden until the user opens it explicitly. outputChannel.appendLine( "✅ Java Message Key Navigator: output console initialized" ); diff --git a/src/utils.ts b/src/utils.ts index 342ca52..fa0483a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,21 +4,24 @@ import * as path from "path"; import * as vscode from "vscode"; import { outputChannel } from "./outputChannel"; -// ── モジュール内キャッシュ ───────────────────────────────── +/** + * In-memory cache of resolved message keys and property values. + */ let propertyCache: Record = {}; /** - * Globパターンでマッチする .properties ファイルを読み込み、 - * キー→値 をキャッシュします。 - * @param customPropertyGlobs テスト時や動的指定用のグロブ配列 + * Loads `.properties` files matched by the configured globs into memory. + * + * @param customPropertyGlobs Optional glob overrides used by tests or dynamic + * configuration flows. */ export async function loadPropertyDefinitions( customPropertyGlobs: string[] = [] ): Promise { - // 1) キャッシュ初期化 + // 1) Reset the in-memory cache. propertyCache = {}; - // 2) パターン配列を決定(引数がなければ設定値を読む) + // 2) Choose the glob list, falling back to workspace settings when needed. const config = vscode.workspace.getConfiguration( "java-message-key-navigator" ); @@ -27,7 +30,7 @@ export async function loadPropertyDefinitions( ? customPropertyGlobs : config.get("propertyFileGlobs", []); - // 3) 各グロブで findFiles → 読み込み + // 3) Expand each glob and load every matching properties file. for (const pattern of globs) { outputChannel.appendLine(`🔍 findFiles pattern: ${pattern}`); const uris = await vscode.workspace.findFiles(pattern); @@ -51,36 +54,35 @@ export async function loadPropertyDefinitions( } /** - * キャッシュされたすべてのキーを返します。 + * Returns every property key currently present in the cache. */ export function getAllPropertyKeys(): string[] { return Object.keys(propertyCache); } /** - * 指定したキーがキャッシュ内に定義されているかチェックします。 + * Returns whether the given key exists in the in-memory cache. */ export function isPropertyDefined(key: string): boolean { return Object.prototype.hasOwnProperty.call(propertyCache, key); } /** - * キャッシュからキーの値を取得します。 + * Returns the cached property value for the given key. */ export function getPropertyValue(key: string): string | undefined { return propertyCache[key]; } /** - * settings.json の java-message-key-navigator.messageKeyExtractionPatterns を元に - * メッセージキー抽出用の正規表現リストを返します。 + * Builds extraction regexes from the extension configuration. */ export function getCustomPatterns(): RegExp[] { const config = vscode.workspace.getConfiguration( "java-message-key-navigator" ); - // 1) 既存のメソッド呼び出し用パターンを組み立て + // 1) Build invocation patterns for configured method calls. const methodPatterns = config.get( "messageKeyExtractionPatterns", [] @@ -92,7 +94,7 @@ export function getCustomPatterns(): RegExp[] { } ); - // 2) アノテーション用の正規表現パターンをそのままコンパイル + // 2) Compile configured annotation regex patterns as-is. const annotationPatterns = config.get( "annotationKeyExtractionPatterns", [] @@ -101,13 +103,12 @@ export function getCustomPatterns(): RegExp[] { (pat) => new RegExp(pat, "g") ); - // 3) 両者を結合して返却 + // 3) Return the combined pattern list. return [...invocationRegexes, ...annotationRegexes]; } /** - * 指定したキーが定義されているファイルと位置を返します。 - * propertyFileGlobs にマッチしたファイルのみを検索対象とします。 + * Finds the location of the first properties entry that defines the key. */ export async function findPropertyLocation( key: string @@ -139,14 +140,14 @@ export async function findPropertyLocation( } /** - * QuickFix から呼ばれて、指定ファイルのソート順に従い - * 指定キーを適切な位置に挿入&カーソル移動します。 + * Inserts a new property key into the selected file and moves the cursor to + * the value position. */ export async function addPropertyKey(key: string, fileToUse: string) { - // 1) 元のソースURIをキャプチャ(使用しない場合は省略可) + // 1) Capture the source URI from the active editor. const sourceUri = vscode.window.activeTextEditor?.document.uri; - // 2) glob→実ファイル解決 + // 2) Resolve a glob or relative path to a concrete properties file. let targetPath = fileToUse; if (!path.isAbsolute(fileToUse) || !fs.existsSync(fileToUse)) { const uris = await vscode.workspace.findFiles(fileToUse); @@ -163,24 +164,24 @@ export async function addPropertyKey(key: string, fileToUse: string) { return; } - // 3) ファイルを読み込んで行を取得(元の改行コードを保持) + // 3) Read the file while preserving its original line ending style. const raw = fs.readFileSync(targetPath, "utf-8"); const eol = raw.includes("\r\n") ? "\r\n" : "\n"; const allLines = raw.split(/\r?\n/); const label = path.basename(targetPath); - // 空行・コメントを除外して既存キー一覧を取得 + // Build the list of existing keys, excluding blank lines and comments. const keys = allLines .map((line) => line.split("=", 1)[0].trim()) .filter((k) => k && !k.startsWith("#")); - // 4) 重複チェック + // 4) Reject duplicate keys. if (keys.includes(key)) { vscode.window.showWarningMessage(`⚠️ "${key}" already exists in ${label}.`); return; } - // --- 5) 挿入位置を決定するためのキー→行番号マップを作成 --- + // 5) Build a key-to-line-number map used to choose the insertion point. const keyLineMap = new Map(); allLines.forEach((line, idx) => { const rawKey = line.split("=", 1)[0].trim(); @@ -189,31 +190,31 @@ export async function addPropertyKey(key: string, fileToUse: string) { } }); - // ソートした全キー+新規キー + // Combine and sort existing keys with the new key. const allKeysSorted = [...keys, key].sort((a, b) => a.localeCompare(b)); const newIdx = allKeysSorted.indexOf(key); let insertIdx: number; if (newIdx === allKeysSorted.length - 1) { - // 新キーが最後ならファイル末尾 + // Insert at the end when the new key sorts last. insertIdx = allLines.length; } else { - // 新キーの次のキーの行番号 + // Otherwise insert before the next key in sorted order. const nextKey = allKeysSorted[newIdx + 1]; insertIdx = keyLineMap.get(nextKey) ?? allLines.length; } - // 6) 配列に挿入 & 保存 + // 6) Insert the new line and save the file. allLines.splice(insertIdx, 0, `${key}=`); fs.writeFileSync(targetPath, allLines.join(eol), "utf-8"); vscode.window.showInformationMessage( `✅ Added "${key}" to ${label}! (line ${insertIdx + 1})` ); - // 7) キャッシュ更新(追加したキーだけ反映し、他ファイルのキャッシュを維持) + // 7) Update only the new key in cache without rebuilding other entries. propertyCache[key] = ""; - // 8) プロパティファイルを1画面で開く + // 8) Open the properties file in the primary editor column. const propDoc = await vscode.workspace.openTextDocument(targetPath); const propEd = await vscode.window.showTextDocument(propDoc, { viewColumn: vscode.ViewColumn.One, @@ -221,11 +222,11 @@ export async function addPropertyKey(key: string, fileToUse: string) { preview: false, }); - // 9) 挿入行の "=" 右側へカーソルを移動 + // 9) Move the caret to the value position on the inserted line. if (propEd) { - // VSCode API で正確に行を取得 + // Read the inserted line back from the VS Code document. const line = propDoc.lineAt(insertIdx); - // 行テキスト中の "=" の位置を探し、見つかればその右、なければ行末 + // Place the caret after "=" when present, otherwise at the end of the line. const eqIdx = line.text.indexOf("="); const eqPos = eqIdx >= 0 ? eqIdx + 1 : line.text.length; const pos = new vscode.Position(insertIdx, eqPos); @@ -238,7 +239,9 @@ export async function addPropertyKey(key: string, fileToUse: string) { ); } -/** settings の propertyFileGlobs から .properties を全取得 */ +/** + * Returns every `.properties` file matched by the configured globs. + */ export async function findPropertiesFiles(): Promise { const globs = vscode.workspace .getConfiguration("java-message-key-navigator") @@ -251,7 +254,9 @@ export async function findPropertiesFiles(): Promise { return uris; } -/** URI の .properties を行ごとに読み込んで返す */ +/** + * Opens a properties file and returns its contents split into lines. + */ export async function readPropertiesFile( uri: vscode.Uri ): Promise<{ lines: string[] }> { @@ -259,7 +264,9 @@ export async function readPropertiesFile( return { lines: doc.getText().split(/\r?\n/) }; } -/** キーに対応する値(右辺)を最初にヒットした .properties から返却 */ +/** + * Returns the first property value found for the given key. + */ export async function getMessageValueForKey( key: string ): Promise { @@ -281,8 +288,9 @@ export async function getMessageValueForKey( } /** - * 指定ファイルパスが、チェック対象外ディレクトリ配下かどうかを判定する - * @param filePath 絶対パス or ワークスペースルートからのパス + * Returns whether the given file path should be excluded from validation. + * + * @param filePath Absolute path or workspace-relative path. */ export function isExcludedFile(filePath: string): boolean { const excludedDirs = [ @@ -297,7 +305,7 @@ export function isExcludedFile(filePath: string): boolean { "/src/test/", "/src/generated/", ]; - // Windowsでも動作するようパス区切りをnormalize + // Normalize path separators so the check works on Windows as well. const normalized = filePath.replace(/\\/g, "/"); return excludedDirs.some((dir) => normalized.includes(dir)); } diff --git a/test/diagnostic.test.ts b/test/diagnostic.test.ts index a96870c..c39bd68 100644 --- a/test/diagnostic.test.ts +++ b/test/diagnostic.test.ts @@ -3,6 +3,9 @@ */ import { strict as assert } from "assert"; +const executeCommand = jest.fn(); +const openTextDocument = jest.fn(); + // ——— outputChannel モック(呼ばれないが import 必須) jest.mock("../src/outputChannel", () => ({ __esModule: true, @@ -16,7 +19,8 @@ jest.mock("../src/utils", () => ({ __esModule: true, getMessageValueForKey })); // ——— vscode API モック jest.mock("vscode", () => ({ __esModule: true, - workspace: { getConfiguration: jest.fn() }, + workspace: { getConfiguration: jest.fn(), openTextDocument }, + commands: { executeCommand }, Diagnostic: class { constructor( public range: any, @@ -28,12 +32,54 @@ jest.mock("vscode", () => ({ Range: class { constructor(public start: any, public end: any) {} }, + Position: class { + constructor(public line: number, public character: number) {} + }, })); import * as vscode from "vscode"; import type { TextDocument, DiagnosticCollection } from "vscode"; import { validatePlaceholders } from "../src/diagnostic"; +function findIdentifierAtOffset(source: string, offset: number): string | null { + if (offset < 0 || offset >= source.length) { + return null; + } + + const isIdentChar = (ch: string) => /[A-Za-z0-9_$]/.test(ch); + let start = offset; + while (start > 0 && isIdentChar(source[start - 1])) { + start--; + } + + let end = offset; + while (end < source.length && isIdentChar(source[end])) { + end++; + } + + const ident = source.slice(start, end); + return ident || null; +} + +function isThrowableLikeIdentifier(name: string): boolean { + return /^(e|ex|err|error|exception|throwable|cause)$/i.test(name) || + /(exception|throwable|cause|error)/i.test(name); +} + +function makeLocationLink(fsPath: string, line = 0, character = 0) { + return { + targetUri: { fsPath, toString: () => fsPath }, + targetSelectionRange: { start: { line, character } }, + }; +} + +function makeLocation(fsPath: string, line = 0, character = 0) { + return { + uri: { fsPath, toString: () => fsPath }, + range: { start: { line, character } }, + }; +} + describe("validatePlaceholders", () => { let doc: TextDocument; let collection: DiagnosticCollection; @@ -66,11 +112,50 @@ describe("validatePlaceholders", () => { (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ get: (key: string, def: any) => { - if (key === "messageKeyExtractionPatterns") return patterns; - if (key === "argBuilderPatterns") return argBuilderPatterns; + if (key === "messageKeyExtractionPatterns") { + return patterns; + } + if (key === "argBuilderPatterns") { + return argBuilderPatterns; + } return def; }, }); + + executeCommand.mockImplementation( + async (_command: string, uri: any, position: any) => { + if (uri?.fsPath !== "/fake/Doc.java") { + return []; + } + const ident = findIdentifierAtOffset(text, position?.character ?? -1); + if (!ident || !isThrowableLikeIdentifier(ident)) { + return []; + } + + const typeName = `${ident}Type`; + return [ + { + targetUri: { + fsPath: `/fake/${typeName}.java`, + toString: () => `/fake/${typeName}.java`, + }, + targetSelectionRange: { start: { line: 0, character: 0 } }, + }, + ]; + } + ); + + openTextDocument.mockImplementation(async (uri: any) => { + const fileName = (uri?.fsPath ?? "ThrowableType") + .split("/") + .pop() + ?.replace(/\.java$/, "") || "ThrowableType"; + return { + uri, + lineCount: 1, + lineAt: () => ({ text: `class ${fileName} extends RuntimeException {` }), + }; + }); }); it("does nothing when languageId isn't java", async () => { @@ -297,6 +382,293 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); + it("varargs形式で末尾が通常識別子でも例外扱いせず診断されないこと", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, status)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}, C {2}"); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("varargs形式で余剰末尾引数が Throwable 型なら診断されないこと", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([ + { + targetUri: { fsPath: "/fake/MyThrowable.java", toString: () => "/fake/MyThrowable.java" }, + targetSelectionRange: { start: { line: 0, character: 13 } }, + }, + ]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/MyThrowable.java", toString: () => "/fake/MyThrowable.java" }, + lineCount: 1, + lineAt: () => ({ text: "class MyThrowable extends RuntimeException {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("varargs形式で余剰末尾引数が String 型なら診断されること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, status)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([ + { + targetUri: { fsPath: "/fake/Status.java", toString: () => "/fake/Status.java" }, + targetSelectionRange: { start: { line: 0, character: 13 } }, + }, + ]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/Status.java", toString: () => "/fake/Status.java" }, + lineCount: 1, + lineAt: () => ({ text: "class Status extends String {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + expect(seen[0][0].message).toMatch(/Placeholder count|argument count/); + }); + + it("型解決不能時は安全側で余剰末尾引数を診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue(undefined); + openTextDocument.mockResolvedValue(undefined); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + expect(seen[0][0].message).toMatch(/Placeholder count|argument count/); + }); + + it("型定義解決コマンドが例外を投げた場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockRejectedValue(new Error("type lookup failed")); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("型定義 location に選択レンジがない場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([ + { + targetUri: { fsPath: "/fake/Broken.java", toString: () => "/fake/Broken.java" }, + }, + ]); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("型定義ファイルを開けない場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([makeLocationLink("/fake/Missing.java")]); + openTextDocument.mockRejectedValue(new Error("open failed")); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("型定義ファイルに class/record 宣言がない場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([makeLocationLink("/fake/NoDecl.java")]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/NoDecl.java", toString: () => "/fake/NoDecl.java" }, + lineCount: 1, + lineAt: () => ({ text: "/* no declaration */" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("型名自体が RuntimeException の場合は診断されないこと", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([makeLocation("/fake/RuntimeException.java")]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/RuntimeException.java", toString: () => "/fake/RuntimeException.java" }, + lineCount: 1, + lineAt: () => ({ text: "class RuntimeException {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("継承句がない通常型なら診断されること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([makeLocationLink("/fake/PlainType.java")]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/PlainType.java", toString: () => "/fake/PlainType.java" }, + lineCount: 1, + lineAt: () => ({ text: "class PlainType {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("基底型の型解決コマンドが失敗した場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockImplementation(async (_command: string, uri: any) => { + if (uri?.fsPath === "/fake/Doc.java") { + return [makeLocationLink("/fake/CustomThrowable.java")]; + } + throw new Error("base lookup failed"); + }); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/CustomThrowable.java", toString: () => "/fake/CustomThrowable.java" }, + lineCount: 1, + lineAt: () => ({ text: "class CustomThrowable extends BaseThrowable {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("基底型の型解決結果が undefined の場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockImplementation(async (_command: string, uri: any) => { + if (uri?.fsPath === "/fake/Doc.java") { + return [makeLocationLink("/fake/CustomThrowable.java")]; + } + return undefined; + }); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/CustomThrowable.java", toString: () => "/fake/CustomThrowable.java" }, + lineAt: () => ({ text: "class CustomThrowable extends BaseThrowable {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("基底型を再帰的にたどって RuntimeException に到達した場合は診断されないこと", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockImplementation(async (_command: string, uri: any) => { + if (uri?.fsPath === "/fake/Doc.java") { + return [makeLocationLink("/fake/CustomThrowable.java")]; + } + if (uri?.fsPath === "/fake/CustomThrowable.java") { + return [makeLocation("/fake/BaseThrowable.java")]; + } + return []; + }); + openTextDocument.mockImplementation(async (uri: any) => { + if (uri?.fsPath === "/fake/CustomThrowable.java") { + return { + uri, + lineCount: 1, + lineAt: () => ({ text: "class CustomThrowable extends BaseThrowable {" }), + }; + } + return { + uri, + lineCount: 1, + lineAt: () => ({ text: "class BaseThrowable extends RuntimeException {" }), + }; + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("基底型解決が深すぎる場合は安全側で診断すること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockImplementation(async (_command: string, uri: any) => { + const current = uri?.fsPath ?? "/fake/Doc.java"; + if (current === "/fake/Doc.java") { + return [makeLocationLink("/fake/Type0.java")]; + } + const match = current.match(/Type(\d+)\.java$/); + if (!match) { + return []; + } + const idx = Number(match[1]); + return [makeLocationLink(`/fake/Type${idx + 1}.java`)]; + }); + openTextDocument.mockImplementation(async (uri: any) => { + const current = uri?.fsPath ?? "/fake/Type0.java"; + const match = current.match(/Type(\d+)\.java$/); + const idx = match ? Number(match[1]) : 0; + return { + uri, + lineCount: 1, + lineAt: () => ({ text: `class Type${idx} extends Type${idx + 1} {` }), + }; + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + }); + + it("型定義ドキュメントに lineCount がなくても継承解決できること", async () => { + patterns = ["log"]; + text = `log("MSG", customerId, orderId, ex)`; + getMessageValueForKey.mockResolvedValue("A {0}, B {1}"); + + executeCommand.mockResolvedValue([makeLocation("/fake/RuntimeException.java")]); + openTextDocument.mockResolvedValue({ + uri: { fsPath: "/fake/RuntimeException.java", toString: () => "/fake/RuntimeException.java" }, + lineAt: () => ({ text: "class RuntimeException {" }), + }); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + it("varargs形式で引数が多すぎる場合はエラーになること", async () => { patterns = ["log"]; text = `log("MSG", "A", "B")`; diff --git a/test/extension.cacheIndexing.test.ts b/test/extension.cacheIndexing.test.ts index 7ea38be..6420436 100644 --- a/test/extension.cacheIndexing.test.ts +++ b/test/extension.cacheIndexing.test.ts @@ -607,6 +607,28 @@ describe("activate cache/index behavior (additional cases)", () => { await expect(activate(context)).resolves.toBeUndefined(); }); + it("createFileSystemWatcher が未提供でも propertyFileGlobs 設定変更時に失敗しない", async () => { + mockWorkspace.createFileSystemWatcher = undefined; + getConfiguration.mockReturnValue({ + get: jest.fn().mockImplementation((key: string) => { + if (key === "propertyFileGlobs") { + return ["src/main/resources/**/*.properties"]; + } + return []; + }), + }); + + await activate(context); + + expect(onConfigChangeCb).toBeDefined(); + expect(() => + onConfigChangeCb?.({ + affectsConfiguration: (key: string) => + key === "java-message-key-navigator.propertyFileGlobs", + }) + ).not.toThrow(); + }); + it("queueValidation のタスク失敗時にエラーログを出す", async () => { findFiles.mockRejectedValueOnce(new Error("queue boom")); await activate(context);