From 13b12b8ae05a66bb87c398dd62e170b68680416e Mon Sep 17 00:00:00 2001 From: y-ok Date: Mon, 30 Mar 2026 23:57:03 +0900 Subject: [PATCH] release 1.1.0: infer key contexts and simplify config --- CHANGELOG.md | 42 ++ README.md | 91 +--- benchmark/run-benchmarks.js | 5 - package-lock.json | 4 +- package.json | 32 +- src/CompletionProvider.ts | 42 +- src/DefinitionProvider.ts | 2 +- src/HoverProvider.ts | 2 +- src/PropertyValidator.ts | 2 +- src/diagnostic.ts | 165 ++++-- src/extension.ts | 20 +- src/inference.ts | 584 ++++++++++++++++++++++ src/utils.ts | 34 +- test/CompletionProvider.inference.test.ts | 84 ++++ test/CompletionProvider.test.ts | 113 ++--- test/diagnostic.inference.test.ts | 97 ++++ test/diagnostic.test.ts | 273 +++++++--- test/extension.cacheIndexing.test.ts | 38 +- test/inference.test.ts | 191 +++++++ test/utils.inference-patterns.test.ts | 87 ++++ test/utils.test.ts | 23 +- 21 files changed, 1500 insertions(+), 431 deletions(-) create mode 100644 src/inference.ts create mode 100644 test/CompletionProvider.inference.test.ts create mode 100644 test/diagnostic.inference.test.ts create mode 100644 test/inference.test.ts create mode 100644 test/utils.inference-patterns.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3cbc2..2dc190f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [1.1.0] - 2026-03-30 + +### Added (1.1.0) + +- Added inference-based key context detection (`src/inference.ts`) for: + - message-key invocation methods inferred from Java source and loaded keys, + - annotation key attributes inferred from Java annotations. +- Added placeholder argument-count inference for helper calls that pass + `Object[]`-style return values to logger/message methods. +- Added inference-focused regression tests: + - `test/inference.test.ts`, + - `test/diagnostic.inference.test.ts`, + - `test/CompletionProvider.inference.test.ts`, + - `test/utils.inference-patterns.test.ts`. + +### Changed (1.1.0) + +- Bumped extension version to **1.1.0**. +- Updated completion, hover/definition key extraction helpers, and placeholder + validation to use inferred contexts instead of user-provided extraction + patterns. +- Updated benchmark runtime and validation cache tests to align with + inference-based behavior. +- Updated `README.md` to document automatic context inference and the simplified + configuration surface. + +### Removed (1.1.0) + +- Removed configuration-driven extraction settings: + - `messageKeyExtractionPatterns`, + - `annotationKeyExtractionPatterns`, + - `argBuilderPatterns`. +- Removed related fallback/config-branch handling that depended on those + settings. + +### Fixed (1.1.0) + +- Fixed placeholder mismatch false positives for helper-call arguments where + the helper method returns `Object[]` elements matching message placeholders. + +--- + ## [1.0.17] - 2026-03-29 ### Fixed (1.0.17) diff --git a/README.md b/README.md index 712b05e..7c8fed8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/y-ok/java-message-key-navigator/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/y-ok/java-message-key-navigator/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/y-ok/java-message-key-navigator/branch/main/graph/badge.svg)](https://codecov.io/gh/y-ok/java-message-key-navigator) -**Java Message Key Navigator** is a VS Code extension designed to supercharge your Java internationalization (I18N) workflow. Hover over any I18N method call to instantly preview the corresponding value from your `.properties` files, and use ⌘/Ctrl + click to jump straight to the exact message-key definition. When a key is missing, you’ll see an automatic warning plus a one-click quick fix that inserts the new key in the correct sorted order—no more manual file edits or guesswork. With customizable extraction patterns and support for multiple property-file globs, this extension keeps your message keys organized and your development flow uninterrupted. +**Java Message Key Navigator** is a VS Code extension designed to supercharge your Java internationalization (I18N) workflow. Hover over any I18N method call to instantly preview the corresponding value from your `.properties` files, and use ⌘/Ctrl + click to jump straight to the exact message-key definition. When a key is missing, you’ll see an automatic warning plus a one-click quick fix that inserts the new key in the correct sorted order—no more manual file edits or guesswork. With automatic key-context inference and support for multiple property-file globs, this extension keeps your message keys organized and your development flow uninterrupted. --- @@ -34,19 +34,9 @@ When you use a key that doesn’t exist in any of your `.properties` files, a wa 5. Splices the new key-value entry into the file, preserving the original line endings (CRLF/LF), rewrites the file in one go, reopens it, and moves your cursor directly to the inserted line. 6. If multiple `.properties` files are present, prompts you with a dialog so you can select which file to add the new key to, giving you precise control over key organization. -**Custom Extraction Patterns** -You can configure method call identifiers used to detect message-key invocations, e.g.: - -```json -"java-message-key-navigator.messageKeyExtractionPatterns": [ - "infrastructureLogger.log", - "appLogger.warn" -] -``` - -Hover, Go to Definition, and undefined-key validation also recognize -`messageSource.getMessage(...)` automatically. -Completion and placeholder validation use only the patterns you configure. +**Automatic Key Context Inference** +The extension infers message-key call and annotation contexts directly from Java source and loaded `.properties` keys. +`messageSource.getMessage(...)` is always recognized automatically. **Multi-File Support** The extension supports multiple `.properties` files specified using glob patterns, for example: @@ -69,98 +59,25 @@ Detects when the number of `{0}`, `{1}`, … placeholders in your `.properties` ``` - 🔍 Treats common exception arguments (e.g. `e`, `ex`, `exceptionObj`) as non-placeholder arguments in logger-style calls -- 🔍 Recognizes argument-builder methods configured via `argBuilderPatterns` (see [Configuration](#️-configuration)) - - ```java - // With argBuilderPatterns: [{ "pattern": "buildArgs", "argCount": 1 }] - // message.properties: PLF1032=Request URI: {0} - // → 1 placeholder matches argCount 1 ✅ - infrastructureLogger.log("PLF1032", buildArgs(requestUri)); - ``` - - ❌ Highlights any mismatch with a red squiggly underline in the editor for immediate correction -**Annotation Key Extraction** -Define regular-expression patterns to pull keys out of annotation attributes. For example, to treat the `start`, `end` and `exception` values in your -`@LogStartEnd(start="…", end="…", exception="…")` annotation as message keys: - -```jsonc -"java-message-key-navigator.annotationKeyExtractionPatterns": [ - "@LogStartEnd\\(\\s*start\\s*=\\s*\"([^\\\"]+)\"", - "@LogStartEnd\\(.*?end\\s*=\\s*\"([^\\\"]+)\"", - "@LogStartEnd\\(.*?exception\\s*=\\s*\"([^\\\"]+)\"" -] -``` - ## ⚙️ Configuration Add these to your **User** or **Workspace** `settings.json`: ```jsonc { - // Which method calls carry your I18N keys (method identifier strings) - "java-message-key-navigator.messageKeyExtractionPatterns": [ - "infrastructureLogger.log", - "appLogger.warn", - ], - // Which .properties files to read & write (glob patterns) "java-message-key-navigator.propertyFileGlobs": [ "src/main/resources/message*.properties", "src/main/resources/validation/**/*.properties", ], - - // Regex patterns to extract I18N keys from @LogStartEnd(start="…", end="…", exception="…") annotation - "java-message-key-navigator.annotationKeyExtractionPatterns": [ - "@LogStartEnd\\(\\s*start\\s*=\\s*\"([^\\\"]+)\"", - "@LogStartEnd\\(.*?end\\s*=\\s*\"([^\\\"]+)\"", - "@LogStartEnd\\(.*?exception\\s*=\\s*\"([^\\\"]+)\"", - ], - - // Methods that build argument arrays with a known argument count - "java-message-key-navigator.argBuilderPatterns": [ - { "pattern": "buildArgs", "argCount": 1 }, - { "pattern": "createLogParams", "argCount": 2 }, - ], } ``` | Setting | Description | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `messageKeyExtractionPatterns` (array) | Method identifier strings used to detect target calls (e.g. `infrastructureLogger.log`) | -| `annotationKeyExtractionPatterns` (array) | Regex patterns for annotations to scan for keys (e.g. values of `start`, `end`, `exception` in `@LogStartEnd`) | | `propertyFileGlobs` (array) | Glob patterns for your `.properties` files to include in look-up and auto-insertion | -| `argBuilderPatterns` (array of objects) | Methods that build argument arrays with a known count. Each entry has `pattern` (method name) and `argCount` (number of arguments it produces). See below. | - -### argBuilderPatterns - -When placeholder validation encounters a method call as the argument expression instead of an inline array literal (`new Object[] {…}`), it cannot determine the argument count statically. The `argBuilderPatterns` setting lets you tell the extension how many arguments a given helper method produces. - -```jsonc -"java-message-key-navigator.argBuilderPatterns": [ - { "pattern": "buildArgs", "argCount": 1 }, - { "pattern": "createLogParams", "argCount": 2 } -] -``` - -**How it works:** - -- `pattern` — The method name to match. Matches bare calls (`buildArgs(…)`), qualified calls (`Utils.buildArgs(…)`), and `this.buildArgs(…)`. -- `argCount` — The number of placeholder arguments the method produces. Used in place of static counting for placeholder validation. - -**Example:** - -```java -// message.properties: PLF1032=Request URI: {0} - -// Without argBuilderPatterns → extension cannot validate argument count -infrastructureLogger.log("PLF1032", buildArgs(requestUri)); - -// With { "pattern": "buildArgs", "argCount": 1 } → validates that -// 1 placeholder ({0}) matches argCount 1 ✅ -``` - -When no pattern matches, the extension falls back to its default behavior (treating the expression as a single argument). --- diff --git a/benchmark/run-benchmarks.js b/benchmark/run-benchmarks.js index 1f34a85..4e61ec2 100644 --- a/benchmark/run-benchmarks.js +++ b/benchmark/run-benchmarks.js @@ -395,8 +395,6 @@ function clearOutModuleCache(outDir) { function createBenchmarkRuntime({ workspaceRoot, propertyFileGlobs, - messagePatterns, - annotationPatterns, }) { const commandHandlers = new Map(); const callbacks = { @@ -498,9 +496,6 @@ function createBenchmarkRuntime({ const configValues = { "java-message-key-navigator.propertyFileGlobs": propertyFileGlobs, - "java-message-key-navigator.messageKeyExtractionPatterns": messagePatterns, - "java-message-key-navigator.annotationKeyExtractionPatterns": - annotationPatterns, }; function registerCallback(bucket, cb) { diff --git a/package-lock.json b/package-lock.json index 931a9d2..b1ee9cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "java-message-key-navigator", - "version": "1.0.17", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "java-message-key-navigator", - "version": "1.0.17", + "version": "1.1.0", "devDependencies": { "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index 47040f0..fa85b56 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.17", + "version": "1.1.0", "icon": "icon.png", "engines": { "vscode": "^1.85.0" @@ -19,11 +19,6 @@ "configuration": { "title": "Java Message Key Navigator", "properties": { - "java-message-key-navigator.messageKeyExtractionPatterns": { - "type": "array", - "description": "Specifies the method invocation patterns (regular expressions) to target for extracting I18N message keys. For example: infrastructureLogger\\.log. Completion will only be enabled within method calls matching the configured patterns.", - "default": [] - }, "java-message-key-navigator.propertyFileGlobs": { "type": "array", "description": "Paths to additional .properties files to include in validation (supports glob patterns)", @@ -31,31 +26,6 @@ "type": "string" }, "default": [] - }, - "java-message-key-navigator.annotationKeyExtractionPatterns": { - "type": "array", - "description": "Regex patterns to extract I18N keys from annotations.", - "default": [] - }, - "java-message-key-navigator.argBuilderPatterns": { - "type": "array", - "description": "Defines methods that build argument arrays with a known argument count. When a method call matching a pattern is used as the argument expression, the configured argCount is used for placeholder validation.", - "items": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Method name to match (e.g. \"buildArgs\"). Matches bare calls and qualified calls like Utils.buildArgs(...)." - }, - "argCount": { - "type": "integer", - "minimum": 0, - "description": "The number of arguments the method produces." - } - }, - "required": ["pattern", "argCount"] - }, - "default": [] } } }, diff --git a/src/CompletionProvider.ts b/src/CompletionProvider.ts index 368f4d9..0300f03 100644 --- a/src/CompletionProvider.ts +++ b/src/CompletionProvider.ts @@ -1,5 +1,10 @@ import * as vscode from "vscode"; import { getAllPropertyKeys, getPropertyValue } from "./utils"; +import { + inferAnnotationTargets, + inferMethodPatterns, + matchesInferredCompletionContext, +} from "./inference"; /** * Provides completion candidates for message keys while editing supported Java @@ -15,32 +20,27 @@ export class MessageKeyCompletionProvider document: vscode.TextDocument, position: vscode.Position ) { - const config = vscode.workspace.getConfiguration( - "java-message-key-navigator" - ); - const methodPatterns: string[] | undefined = config.get( - "messageKeyExtractionPatterns" - ); - const annotationPatterns: string[] | undefined = config.get( - "annotationKeyExtractionPatterns" - ); - const patterns = [...(methodPatterns || []), ...(annotationPatterns || [])]; - if (patterns.length === 0) { + const lineUntilPosition = document + .lineAt(position) + .text.substring(0, position.character); + if (typeof (document as any).getText !== "function") { return undefined; } - - 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) + const text = document.getText(); + const definedKeys = new Set(getAllPropertyKeys()); + const inferredMethods = inferMethodPatterns(text, definedKeys); + const inferredAnnotations = inferAnnotationTargets(text, definedKeys); + const matchesPattern = matchesInferredCompletionContext( + lineUntilPosition, + inferredMethods, + inferredAnnotations ); - if (!matchesPattern) {return undefined;} + + 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); const inputMatch = lineUntilPosition.match(/["']([^"']*)$/); const input = inputMatch ? inputMatch[1] : ""; diff --git a/src/DefinitionProvider.ts b/src/DefinitionProvider.ts index a8cd181..b6f48bd 100644 --- a/src/DefinitionProvider.ts +++ b/src/DefinitionProvider.ts @@ -27,7 +27,7 @@ export class PropertiesDefinitionProvider implements vscode.DefinitionProvider { const text = document.getText(); const offset = document.offsetAt(position); - const patterns = getCustomPatterns(); + const patterns = getCustomPatterns(text); for (const regex of patterns) { regex.lastIndex = 0; diff --git a/src/HoverProvider.ts b/src/HoverProvider.ts index 13af03f..533f58d 100644 --- a/src/HoverProvider.ts +++ b/src/HoverProvider.ts @@ -17,7 +17,7 @@ export class PropertiesHoverProvider implements vscode.HoverProvider { const offset = document.offsetAt(position); // Collect configured extraction patterns such as method calls and annotations. - const patterns = getCustomPatterns(); + const patterns = getCustomPatterns(text); const processedKeys = new Set(); for (const regex of patterns) { diff --git a/src/PropertyValidator.ts b/src/PropertyValidator.ts index cfbb975..cffe445 100644 --- a/src/PropertyValidator.ts +++ b/src/PropertyValidator.ts @@ -25,7 +25,7 @@ export async function validateProperties( } const text = document.getText(); - const patterns = getCustomPatterns(); + const patterns = getCustomPatterns(text); const errors: vscode.Diagnostic[] = []; outputChannel.appendLine("🔍 Starting properties validation..."); diff --git a/src/diagnostic.ts b/src/diagnostic.ts index 1c95941..04d4996 100644 --- a/src/diagnostic.ts +++ b/src/diagnostic.ts @@ -1,13 +1,6 @@ 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; -} +import { getAllPropertyKeys, getMessageValueForKey } from "./utils"; +import { inferMethodPatterns, parseCallLikeArg } from "./inference"; /** * Represents a split argument together with its start offset inside the call. @@ -29,12 +22,7 @@ export async function validatePlaceholders( const seenDiagnostics = new Set(); const text = document.getText(); - const config = vscode.workspace.getConfiguration("java-message-key-navigator"); - const patterns = config.get("messageKeyExtractionPatterns", []); - const argBuilderPatterns = config.get( - "argBuilderPatterns", - [] - ); + const patterns = inferMethodPatterns(text, new Set(getAllPropertyKeys())); const regexes = patterns .map(normalizeMethodPattern) .filter((pat) => pat.length > 0) @@ -156,11 +144,29 @@ export async function validatePlaceholders( } // 4. Count the effective arguments after applying supported shortcuts. - const actualArgCount = countActualArguments( - args, - expectedArgCount, - argBuilderPatterns - ); + let actualArgCount = countActualArguments(args, expectedArgCount); + if (args.length === 1) { + const singleArgText = args[0].text; + const isJoinCall = /\.join\s*\(/.test(singleArgText); + const isArrayLiteral = + /^new\s+[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\[\s*\]\s*{[\s\S]*}$/.test( + singleArgText.trim() + ); + if (isJoinCall || isArrayLiteral) { + // Preserve existing special handling for join()/array literal inputs. + actualArgCount = countActualArguments(args, expectedArgCount); + } else { + const inferred = parseCallLikeArg(singleArgText); + if (inferred) { + const inferredArrayArgCount = inferReturnedArrayArgCountForCall( + text, + inferred.methodName, + inferred.argCount + ); + actualArgCount = inferredArrayArgCount ?? inferred.argCount; + } + } + } // 5. Emit a diagnostic when the placeholder count does not match. if ( @@ -370,33 +376,12 @@ function pushArgPart(result: ArgPart[], raw: string, tokenStart: number): void { }); } -/** - * Matches an argument-builder helper and returns the number of arguments it - * contributes. - */ -function matchArgBuilderPattern( - argText: string, - patterns: ArgBuilderPattern[] -): number | null { - const trimmed = argText.trim(); - for (const { pattern, argCount } of patterns) { - const esc = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match buildArgs(...), Utils.buildArgs(...), or this.buildArgs(...). - const re = new RegExp(`(?:^|\\.)${esc}\\s*\\(`); - if (re.test(trimmed)) { - return argCount; - } - } - return null; -} - /** * Counts effective placeholder arguments after applying supported shortcuts. */ function countActualArguments( args: ArgPart[], - expectedArgCount: number, - argBuilderPatterns: ArgBuilderPattern[] + expectedArgCount: number ): number { const argTexts = args.map((arg) => arg.text); if ( @@ -422,18 +407,100 @@ function countActualArguments( return expectedArgCount; } - const builderArgCount = matchArgBuilderPattern( - argTexts[0], - argBuilderPatterns + return 1; + } + + return argTexts.filter((e) => e.trim() !== "").length; +} + +/** + * Infers argument count when a single helper-call argument resolves to a + * locally-declared method that returns an array literal. + * + * This logic targets syntactically valid Java method declarations. + */ +function inferReturnedArrayArgCountForCall( + source: string, + methodName: string, + callArgCount: number +): number | null { + const declarationRegex = new RegExp( + `\\b(?:public|protected|private|static|final|synchronized|native|abstract|strictfp|\\s)+` + + `[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*\\s*\\[\\s*]\\s*` + + `${escapeRegExp(methodName)}\\s*\\(([^)]*)\\)\\s*\\{`, + "g" + ); + + let declarationMatch: RegExpExecArray | null; + while ((declarationMatch = declarationRegex.exec(source)) !== null) { + const declaredParamCount = countMethodParams(declarationMatch[1].trim()); + if (declaredParamCount !== callArgCount) { + continue; + } + + const bodyStart = declarationRegex.lastIndex - 1; + const bodyEnd = findMatchingBrace(source, bodyStart); + const body = source.slice(bodyStart + 1, bodyEnd); + const arrayReturnMatch = body.match( + /return\s+new\s+[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\[\s*]\s*{([\s\S]*?)}\s*;/ ); - if (builderArgCount !== null) { - return builderArgCount; + if (!arrayReturnMatch) { + continue; } - return 1; + return safeSplit(arrayReturnMatch[1]).filter((item) => item.trim() !== "").length; } - return argTexts.filter((e) => e.trim() !== "").length; + return null; +} + +/** + * Counts method parameters while ignoring commas inside generic type brackets. + */ +function countMethodParams(paramsText: string): number { + if (paramsText.length === 0) { + return 0; + } + + let count = 1; + let angleDepth = 0; + for (let i = 0; i < paramsText.length; i++) { + const ch = paramsText[i]; + if (ch === "<") { + angleDepth++; + continue; + } + if (ch === ">" && angleDepth > 0) { + angleDepth--; + continue; + } + if (ch === "," && angleDepth === 0) { + count++; + } + } + return count; +} + +/** + * Finds the matching `}` for a method body that starts with `{`. + */ +function findMatchingBrace(source: string, openBraceIndex: number): number { + let depth = 0; + let endIndex = openBraceIndex; + for (let i = openBraceIndex; i < source.length; i++) { + if (source[i] === "{") { + depth++; + continue; + } + if (source[i] === "}") { + depth--; + if (depth === 0) { + endIndex = i; + break; + } + } + } + return endIndex; } /** diff --git a/src/extension.ts b/src/extension.ts index c450a2e..0f24dd9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -493,24 +493,12 @@ export async function activate( const affectsPropertyGlobs = e.affectsConfiguration( "java-message-key-navigator.propertyFileGlobs" ); - const affectsExtractionPatterns = - e.affectsConfiguration( - "java-message-key-navigator.messageKeyExtractionPatterns" - ) || - e.affectsConfiguration( - "java-message-key-navigator.annotationKeyExtractionPatterns" - ) || - e.affectsConfiguration( - "java-message-key-navigator.argBuilderPatterns" - ); - if (!affectsPropertyGlobs && !affectsExtractionPatterns) { + if (!affectsPropertyGlobs) { return; } - if (affectsPropertyGlobs) { - propertyCacheDirty = true; - propertyGlobSignature = ""; - createPropWatchers(getPropertyFileGlobs()); - } + propertyCacheDirty = true; + propertyGlobSignature = ""; + createPropWatchers(getPropertyFileGlobs()); void queueValidation(async () => { await validateWorkspaceJavaFiles(); }); diff --git a/src/inference.ts b/src/inference.ts new file mode 100644 index 0000000..e932054 --- /dev/null +++ b/src/inference.ts @@ -0,0 +1,584 @@ +interface ParsedMethodCall { + methodPath: string; + args: string[]; +} + +interface ParsedAnnotationAttr { + annotationName: string; + attrName: string; + value: string; +} + +function isIdentifierStart(ch: string): boolean { + return /[A-Za-z_$]/.test(ch); +} + +function isIdentifierPart(ch: string): boolean { + return /[A-Za-z0-9_$]/.test(ch); +} + +function isWhitespace(ch: string): boolean { + return /\s/.test(ch); +} + +function unescapeSimpleString(raw: string): string { + let out = ""; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch === "\\" && i + 1 < raw.length) { + i++; + const next = raw[i]; + if (next === "n") {out += "\n";} + else if (next === "r") {out += "\r";} + else if (next === "t") {out += "\t";} + else {out += next;} + continue; + } + out += ch; + } + return out; +} + +function parseStringLiteral(token: string): string | null { + const trimmed = token.trim(); + if (trimmed.length < 2) { + return null; + } + const quote = trimmed[0]; + if ((quote !== '"' && quote !== "'") || trimmed[trimmed.length - 1] !== quote) { + return null; + } + return unescapeSimpleString(trimmed.slice(1, -1)); +} + +function stripComments(source: string): string { + let out = ""; + let i = 0; + let inLineComment = false; + let inBlockComment = false; + let inSingle = false; + let inDouble = false; + + while (i < source.length) { + const ch = source[i]; + const next = i + 1 < source.length ? source[i + 1] : ""; + + if (inLineComment) { + if (ch === "\n") { + inLineComment = false; + out += "\n"; + } else { + out += " "; + } + i++; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + out += " "; + i += 2; + } else { + out += ch === "\n" ? "\n" : " "; + i++; + } + continue; + } + if (inSingle) { + out += ch; + if (ch === "'" && source[i - 1] !== "\\") { + inSingle = false; + } + i++; + continue; + } + if (inDouble) { + out += ch; + if (ch === '"' && source[i - 1] !== "\\") { + inDouble = false; + } + i++; + continue; + } + + if (ch === "/" && next === "/") { + inLineComment = true; + out += " "; + i += 2; + continue; + } + if (ch === "/" && next === "*") { + inBlockComment = true; + out += " "; + i += 2; + continue; + } + if (ch === "'") { + inSingle = true; + out += ch; + i++; + continue; + } + if (ch === '"') { + inDouble = true; + out += ch; + i++; + continue; + } + + out += ch; + i++; + } + + return out; +} + +function findMatchingParen(source: string, openIndex: number): number { + let depth = 0; + let inSingle = false; + let inDouble = false; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + if (inSingle) { + if (ch === "'" && source[i - 1] !== "\\") { + inSingle = false; + } + continue; + } + if (inDouble) { + if (ch === '"' && source[i - 1] !== "\\") { + inDouble = false; + } + continue; + } + if (ch === "'") { + inSingle = true; + continue; + } + if (ch === '"') { + inDouble = true; + continue; + } + if (ch === "(") { + depth++; + continue; + } + if (ch === ")") { + depth--; + if (depth === 0) { + return i; + } + } + } + return -1; +} + +function splitTopLevelArgs(argsSource: string): string[] { + const args: string[] = []; + let current = ""; + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < argsSource.length; i++) { + const ch = argsSource[i]; + if (inSingle) { + current += ch; + if (ch === "'" && argsSource[i - 1] !== "\\") { + inSingle = false; + } + continue; + } + if (inDouble) { + current += ch; + if (ch === '"' && argsSource[i - 1] !== "\\") { + inDouble = false; + } + continue; + } + if (ch === "'") { + inSingle = true; + current += ch; + continue; + } + if (ch === '"') { + inDouble = true; + current += ch; + continue; + } + if (ch === "(") { + parenDepth++; + current += ch; + continue; + } + if (ch === ")") { + parenDepth--; + current += ch; + continue; + } + if (ch === "{") { + braceDepth++; + current += ch; + continue; + } + if (ch === "}") { + braceDepth--; + current += ch; + continue; + } + if (ch === "[") { + bracketDepth++; + current += ch; + continue; + } + if (ch === "]") { + bracketDepth--; + current += ch; + continue; + } + if ( + ch === "," && + parenDepth === 0 && + braceDepth === 0 && + bracketDepth === 0 + ) { + const token = current.trim(); + if (token.length > 0) { + args.push(token); + } + current = ""; + continue; + } + current += ch; + } + + const tail = current.trim(); + if (tail.length > 0) { + args.push(tail); + } + return args; +} + +function readMethodPathBackward(source: string, fromIndex: number): string | null { + let i = fromIndex; + while (i >= 0 && isWhitespace(source[i])) { + i--; + } + if (i < 0 || !isIdentifierPart(source[i])) { + return null; + } + const end = i; + while (i >= 0 && (isIdentifierPart(source[i]) || source[i] === ".")) { + i--; + } + const path = source.slice(i + 1, end + 1); + if (path.length === 0 || path.startsWith(".") || path.endsWith(".")) { + return null; + } + return path; +} + +function simpleName(name: string): string { + const idx = name.lastIndexOf("."); + return idx >= 0 ? name.slice(idx + 1) : name; +} + +function parseMethodCalls(source: string): ParsedMethodCall[] { + const clean = stripComments(source); + const calls: ParsedMethodCall[] = []; + for (let i = 0; i < clean.length; i++) { + if (clean[i] !== "(") { + continue; + } + const methodPath = readMethodPathBackward(clean, i - 1); + if (!methodPath) { + continue; + } + const close = findMatchingParen(clean, i); + if (close < 0) { + continue; + } + calls.push({ + methodPath, + args: splitTopLevelArgs(clean.slice(i + 1, close)), + }); + } + return calls; +} + +function parseNamedAssignment(arg: string): { name: string; valueToken: string } | null { + let inSingle = false; + let inDouble = false; + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + + for (let i = 0; i < arg.length; i++) { + const ch = arg[i]; + if (inSingle) { + if (ch === "'" && arg[i - 1] !== "\\") { + inSingle = false; + } + continue; + } + if (inDouble) { + if (ch === '"' && arg[i - 1] !== "\\") { + inDouble = false; + } + continue; + } + if (ch === "'") { + inSingle = true; + continue; + } + if (ch === '"') { + inDouble = true; + continue; + } + if (ch === "(") {parenDepth++; continue;} + if (ch === ")") {parenDepth--; continue;} + if (ch === "{") {braceDepth++; continue;} + if (ch === "}") {braceDepth--; continue;} + if (ch === "[") {bracketDepth++; continue;} + if (ch === "]") {bracketDepth--; continue;} + + if ( + ch === "=" && + parenDepth === 0 && + braceDepth === 0 && + bracketDepth === 0 + ) { + const name = arg.slice(0, i).trim(); + const valueToken = arg.slice(i + 1).trim(); + if (!name || !valueToken || !isIdentifierStart(name[0])) { + return null; + } + for (let j = 1; j < name.length; j++) { + if (!isIdentifierPart(name[j])) { + return null; + } + } + return { name, valueToken }; + } + } + + return null; +} + +function parseAnnotations(source: string): ParsedAnnotationAttr[] { + const clean = stripComments(source); + const parsed: ParsedAnnotationAttr[] = []; + + for (let i = 0; i < clean.length; i++) { + if (clean[i] !== "@") { + continue; + } + let p = i + 1; + if (p >= clean.length || !isIdentifierStart(clean[p])) { + continue; + } + while (p < clean.length && (isIdentifierPart(clean[p]) || clean[p] === ".")) { + p++; + } + const annotationName = simpleName(clean.slice(i + 1, p)); + while (p < clean.length && isWhitespace(clean[p])) { + p++; + } + if (p >= clean.length || clean[p] !== "(") { + continue; + } + const close = findMatchingParen(clean, p); + if (close < 0) { + continue; + } + const args = splitTopLevelArgs(clean.slice(p + 1, close)); + for (const arg of args) { + const assignment = parseNamedAssignment(arg); + if (!assignment) { + continue; + } + const value = parseStringLiteral(assignment.valueToken); + if (value === null) { + continue; + } + parsed.push({ + annotationName, + attrName: assignment.name, + value, + }); + } + } + + return parsed; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function inferMethodPatterns( + source: string, + definedKeys: Set +): string[] { + const stats = new Map(); + for (const call of parseMethodCalls(source)) { + if (call.args.length === 0) { + continue; + } + const key = parseStringLiteral(call.args[0]); + if (key === null) { + continue; + } + const entry = stats.get(call.methodPath) ?? { positive: 0, negative: 0 }; + if (definedKeys.has(key)) { + entry.positive++; + } else { + entry.negative++; + } + stats.set(call.methodPath, entry); + } + + const result: string[] = []; + for (const [method, stat] of stats.entries()) { + if (stat.positive > 0 && stat.negative === 0) { + result.push(method); + } + } + return result; +} + +function collectAnnotationStats( + source: string, + definedKeys: Set +): Map { + const stats = new Map(); + for (const entry of parseAnnotations(source)) { + const key = `${entry.annotationName}#${entry.attrName}`; + const stat = stats.get(key) ?? { positive: 0, negative: 0 }; + if (definedKeys.has(entry.value)) { + stat.positive++; + } else { + stat.negative++; + } + stats.set(key, stat); + } + return stats; +} + +export function inferAnnotationTargets( + source: string, + definedKeys: Set +): Set { + const result = new Set(); + for (const [key, stat] of collectAnnotationStats(source, definedKeys).entries()) { + if (stat.positive > 0 && stat.negative === 0) { + result.add(key); + } + } + return result; +} + +export function inferAnnotationRegexSources( + source: string, + definedKeys: Set +): string[] { + const result: string[] = []; + for (const key of inferAnnotationTargets(source, definedKeys)) { + const parts = key.split("#"); + const annEsc = escapeRegExp(parts[0]); + const attrEsc = escapeRegExp(parts[1]); + if (parts[1] === "start") { + result.push(`@${annEsc}\\(\\s*${attrEsc}\\s*=\\s*\"([^\\\"]+)\"`); + } else { + result.push(`@${annEsc}\\(.*?${attrEsc}\\s*=\\s*\"([^\\\"]+)\"`); + } + } + return result; +} + +export function parseCallLikeArg( + argText: string +): { methodName: string; argCount: number } | null { + const trimmed = argText.trim(); + const open = trimmed.indexOf("("); + if (open <= 0 || trimmed[trimmed.length - 1] !== ")") { + return null; + } + const methodPath = readMethodPathBackward(trimmed, open - 1); + if (!methodPath) { + return null; + } + const close = findMatchingParen(trimmed, open); + if (close !== trimmed.length - 1) { + return null; + } + const inner = trimmed.slice(open + 1, close).trim(); + const args = inner.length === 0 ? [] : splitTopLevelArgs(inner); + return { methodName: simpleName(methodPath), argCount: args.length }; +} + +function readIdentifierBackward(source: string, fromIndex: number): string | null { + let i = fromIndex; + while (i >= 0 && isWhitespace(source[i])) { + i--; + } + if (i < 0 || !isIdentifierPart(source[i])) { + return null; + } + const end = i; + while (i >= 0 && isIdentifierPart(source[i])) { + i--; + } + const id = source.slice(i + 1, end + 1); + return id.length > 0 && isIdentifierStart(id[0]) ? id : null; +} + +export function matchesInferredCompletionContext( + lineUntilPosition: string, + methodPatterns: string[], + annotationTargets: Set +): boolean { + const quotePos = Math.max( + lineUntilPosition.lastIndexOf('"'), + lineUntilPosition.lastIndexOf("'") + ); + if (quotePos < 0) { + return false; + } + + const beforeQuote = lineUntilPosition.slice(0, quotePos).trimRight(); + if (beforeQuote.endsWith("(")) { + const method = readMethodPathBackward(beforeQuote, beforeQuote.length - 2); + if (!method) { + return false; + } + const methodSimple = simpleName(method); + return methodPatterns.some((p) => p === method || simpleName(p) === methodSimple); + } + + if (!beforeQuote.endsWith("=")) { + return false; + } + const attr = readIdentifierBackward(beforeQuote, beforeQuote.length - 2); + if (!attr) { + return false; + } + const at = beforeQuote.lastIndexOf("@"); + if (at < 0) { + return false; + } + let i = at + 1; + if (i >= beforeQuote.length || !isIdentifierStart(beforeQuote[i])) { + return false; + } + while ( + i < beforeQuote.length && + (isIdentifierPart(beforeQuote[i]) || beforeQuote[i] === ".") + ) { + i++; + } + const annName = simpleName(beforeQuote.slice(at + 1, i)); + return annotationTargets.has(`${annName}#${attr}`); +} diff --git a/src/utils.ts b/src/utils.ts index fa0483a..21d8f65 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,10 @@ import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; import { outputChannel } from "./outputChannel"; +import { + inferAnnotationRegexSources, + inferMethodPatterns, +} from "./inference"; /** * In-memory cache of resolved message keys and property values. @@ -77,30 +81,26 @@ export function getPropertyValue(key: string): string | undefined { /** * Builds extraction regexes from the extension configuration. */ -export function getCustomPatterns(): RegExp[] { - const config = vscode.workspace.getConfiguration( - "java-message-key-navigator" - ); +export function getCustomPatterns(documentText = ""): RegExp[] { + const definedKeys = new Set(getAllPropertyKeys()); + const shouldInfer = documentText.trim().length > 0; + const inferredMethods = shouldInfer + ? inferMethodPatterns(documentText, definedKeys) + : []; + const inferredAnnotationPatterns = shouldInfer + ? inferAnnotationRegexSources(documentText, definedKeys) + : []; - // 1) Build invocation patterns for configured method calls. - const methodPatterns = config.get( - "messageKeyExtractionPatterns", - [] - ); - const invocationRegexes = [...methodPatterns, "messageSource.getMessage"].map( + const invocationRegexes = [...inferredMethods, "messageSource.getMessage"].map( (method) => { const esc = method.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`(?:[\\w$]+\\.)?${esc}\\(\\s*['"]([^'"]+)['"]`, "g"); } ); - // 2) Compile configured annotation regex patterns as-is. - const annotationPatterns = config.get( - "annotationKeyExtractionPatterns", - [] - ); - const annotationRegexes = annotationPatterns.map( - (pat) => new RegExp(pat, "g") + // 2) Compile inferred annotation regex patterns. + const annotationRegexes = inferredAnnotationPatterns.map((pat) => + new RegExp(pat, "g") ); // 3) Return the combined pattern list. diff --git a/test/CompletionProvider.inference.test.ts b/test/CompletionProvider.inference.test.ts new file mode 100644 index 0000000..88db1f2 --- /dev/null +++ b/test/CompletionProvider.inference.test.ts @@ -0,0 +1,84 @@ +import { strict as assert } from "assert"; +import type { CompletionItem, Position, TextDocument } from "vscode"; +import * as vscode from "vscode"; +import * as utils from "../src/utils"; +import { MessageKeyCompletionProvider } from "../src/CompletionProvider"; + +jest.mock("vscode", () => ({ + __esModule: true, + workspace: { getConfiguration: jest.fn() }, + CompletionItem: class { + label: string; + kind: any; + insertText: any; + documentation: any; + constructor(label: string, kind: any) { + this.label = label; + this.kind = kind; + } + }, + CompletionItemKind: { Value: "Value" }, + MarkdownString: class { + value: string; + constructor(v: string) { + this.value = v; + } + }, +})); + +jest.mock("../src/utils", () => ({ + getAllPropertyKeys: jest.fn(), + getPropertyValue: jest.fn(), +})); + +describe("MessageKeyCompletionProvider inference mode", () => { + let provider: MessageKeyCompletionProvider; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new MessageKeyCompletionProvider(); + (utils.getAllPropertyKeys as jest.Mock).mockReturnValue([ + "MSG_START", + "MSG_END", + ]); + (utils.getPropertyValue as jest.Mock).mockImplementation( + (k: string) => `value:${k}` + ); + (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ + get: (_key: string) => [], + }); + }); + + it("returns completion items when inferred method context matches", async () => { + const fullText = [ + `infrastructureLogger.log("MSG_START");`, + `infrastructureLogger.log("`, + ].join("\n"); + const line = `infrastructureLogger.log("`; + const doc = { + getText: () => fullText, + lineAt: () => ({ text: line }), + } as unknown as TextDocument; + const pos = { line: 1, character: line.length } as Position; + + const items = (await provider.provideCompletionItems( + doc, + pos + )) as CompletionItem[]; + assert.strictEqual(items.length, 2); + assert.strictEqual(items[0].insertText, "MSG_START"); + }); + + it("returns undefined when inferred context does not match", async () => { + const fullText = `foo("BAR");`; + const line = `plain("`; + const doc = { + getText: () => fullText, + lineAt: () => ({ text: line }), + } as unknown as TextDocument; + const pos = { line: 0, character: line.length } as Position; + + const result = await provider.provideCompletionItems(doc, pos); + assert.strictEqual(result, undefined); + }); +}); diff --git a/test/CompletionProvider.test.ts b/test/CompletionProvider.test.ts index 7c8d1d7..5c67d2c 100644 --- a/test/CompletionProvider.test.ts +++ b/test/CompletionProvider.test.ts @@ -2,6 +2,11 @@ import { strict as assert } from "assert"; import type { TextDocument, Position, CompletionItem } from "vscode"; import * as vscode from "vscode"; import * as utils from "../src/utils"; +import { + inferAnnotationTargets, + inferMethodPatterns, + matchesInferredCompletionContext, +} from "../src/inference"; import { MessageKeyCompletionProvider } from "../src/CompletionProvider"; jest.mock("vscode", () => { @@ -59,17 +64,24 @@ jest.mock("../src/utils", () => ({ getPropertyValue: jest.fn(), })); +jest.mock("../src/inference", () => ({ + __esModule: true, + inferMethodPatterns: jest.fn(), + inferAnnotationTargets: jest.fn(), + matchesInferredCompletionContext: jest.fn(), +})); + describe("MessageKeyCompletionProvider.provideCompletionItems", () => { let provider: MessageKeyCompletionProvider; let fakeChannel: any; let doc: TextDocument; let currentLine: string; + let currentText: string; beforeEach(() => { jest.resetAllMocks(); provider = new MessageKeyCompletionProvider(); - // utils モック設定 (utils.getAllPropertyKeys as jest.Mock).mockReturnValue([ "user.name", "admin.key", @@ -80,7 +92,10 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { (k: string) => `value:${k}` ); - // stubChannel の仕込み + (inferMethodPatterns as jest.Mock).mockReturnValue(["foo"]); + (inferAnnotationTargets as jest.Mock).mockReturnValue(new Set()); + (matchesInferredCompletionContext as jest.Mock).mockReturnValue(true); + const logs: string[] = []; fakeChannel = { appendLine(msg: string) { @@ -92,27 +107,25 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }; (vscode as any).__setChannel(fakeChannel); - // Document モック + currentText = "class A {}"; doc = { + getText: () => currentText, lineAt: () => ({ text: currentLine }), } as any; }); it("補完パターン設定が undefined の場合は undefined を返す", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => undefined, - }); - currentLine = `foo("`; - const pos = { line: 0, character: currentLine.length } as Position; + doc = { + lineAt: () => ({ text: `foo("` }), + } as any; + const pos = { line: 0, character: 5 } as Position; const res = await provider.provideCompletionItems(doc, pos); assert.strictEqual(res, undefined); }); it("行がいずれのパターンにもマッチしない場合は undefined を返す", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); + (matchesInferredCompletionContext as jest.Mock).mockReturnValue(false); currentLine = `bar("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -121,9 +134,6 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it('プレフィックス空 (foo(") の場合は全キーを配列で返す', async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); currentLine = `foo("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -141,9 +151,6 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("入力プレフィックス “Key” でキーをフィルタリングする", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); currentLine = `foo("Key`; const pos = { line: 0, character: currentLine.length } as Position; @@ -156,9 +163,9 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("パターン設定が空配列の場合は undefined を返す", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => [], - }); + (inferMethodPatterns as jest.Mock).mockReturnValue([]); + (inferAnnotationTargets as jest.Mock).mockReturnValue(new Set()); + (matchesInferredCompletionContext as jest.Mock).mockReturnValue(false); currentLine = `foo("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -167,10 +174,6 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("マッチするパターンかつキャッシュが空の場合は空配列を返す", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); - // キャッシュを空にする (utils.getAllPropertyKeys as jest.Mock).mockReturnValue([]); currentLine = `foo("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -180,9 +183,6 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("マッチしキャッシュにキーがある場合は CompletionItem[] を返す", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["A", "B"]); (utils.getPropertyValue as jest.Mock) .mockReturnValueOnce("VA") @@ -199,16 +199,8 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("パターンが一致し、キャッシュが空の場合は空配列を返す", async () => { - // messageKeyExtractionPatterns に "log("、annotationKeyExtractionPatterns は空を設定 - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: (section: string) => { - if (section === "messageKeyExtractionPatterns") return ["log("]; - if (section === "annotationKeyExtractionPatterns") return []; - return undefined; - }, - }); - // キャッシュを空に (utils.getAllPropertyKeys as jest.Mock).mockReturnValue([]); + (inferMethodPatterns as jest.Mock).mockReturnValue(["log"]); currentLine = `log("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -217,18 +209,10 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("annotationKeyExtractionPatterns でのみマッチする場合も補完が動作する", async () => { - // messageKeyExtractionPatterns は空、annotationKeyExtractionPatterns に '@Anno(' を設定 - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: (section: string) => { - if (section === "messageKeyExtractionPatterns") return []; - if (section === "annotationKeyExtractionPatterns") return ["@Anno("]; - return undefined; - }, - }); - // キャッシュに 2 件のキー + (inferMethodPatterns as jest.Mock).mockReturnValue([]); + (inferAnnotationTargets as jest.Mock).mockReturnValue(new Set(["Anno#value"])); (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["abc", "def"]); (utils.getPropertyValue as jest.Mock).mockReturnValue("VAL"); - // ドキュメント行にアノテーションが含まれる currentLine = `@Anno("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -242,19 +226,8 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("getPropertyValue が undefined の場合は空文字として扱う", async () => { - // パターンは log( だけ - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: (section: string) => { - if (section === "messageKeyExtractionPatterns") return ["log("]; - if (section === "annotationKeyExtractionPatterns") return []; - return undefined; - }, - }); - // キャッシュに 1 件だけ (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["XYZ"]); - // getPropertyValue は undefined (utils.getPropertyValue as jest.Mock).mockReturnValue(undefined); - currentLine = `log("`; const pos = { line: 0, character: currentLine.length } as Position; @@ -262,27 +235,19 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { doc, pos )) as CompletionItem[]; - // value 部分が空文字になる assert.strictEqual(items.length, 1); assert.strictEqual(items[0].label, "XYZ - "); assert.strictEqual(items[0].insertText, "XYZ"); }); it("messageKeyExtractionPatterns と annotationKeyExtractionPatterns 両方にマッチする場合も補完が動作する", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: (section: string) => { - if (section === "messageKeyExtractionPatterns") return ["log("]; - if (section === "annotationKeyExtractionPatterns") return ['@Anno(']; - return undefined; - }, - }); - // getAllPropertyKeys / getPropertyValue の戻り値 (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["X", "Y"]); (utils.getPropertyValue as jest.Mock) .mockReturnValueOnce("VX") - .mockReturnValueOnce("VY"); + .mockReturnValueOnce("VY") + .mockReturnValueOnce("value:X") + .mockReturnValueOnce("value:Y"); - // まず methodPatterns でマッチ currentLine = `log("`; let pos = { line: 0, character: currentLine.length } as Position; let items = (await provider.provideCompletionItems(doc, pos)) as CompletionItem[]; @@ -290,7 +255,6 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { assert.strictEqual(items[0].label, "X - VX"); assert.strictEqual(items[1].label, "Y - VY"); - // 次に annotationPatterns でマッチ currentLine = `@Anno("`; pos = { line: 0, character: currentLine.length } as Position; items = (await provider.provideCompletionItems(doc, pos)) as CompletionItem[]; @@ -300,29 +264,20 @@ describe("MessageKeyCompletionProvider.provideCompletionItems", () => { }); it("lineUntilPosition.match が null のとき(引用符直前以外)inputMatch=null 分岐をテスト", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["A"]); (utils.getPropertyValue as jest.Mock).mockReturnValue("VA"); - // 引用符なしの位置 currentLine = `foo(`; const pos = { line: 0, character: currentLine.length } as Position; const items = (await provider.provideCompletionItems(doc, pos)) as CompletionItem[]; - // input が空文字なので全キーを返す - assert.deepStrictEqual(items.map(i => i.insertText), ["A"]); + assert.deepStrictEqual(items.map((i) => i.insertText), ["A"]); }); it("getPropertyValue が undefined の場合は空文字として扱う分岐(51行目)をテスト", async () => { - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: () => ["foo("], - }); (utils.getAllPropertyKeys as jest.Mock).mockReturnValue(["Z"]); (utils.getPropertyValue as jest.Mock).mockReturnValue(undefined); currentLine = `foo("`; const pos = { line: 0, character: currentLine.length } as Position; const items = (await provider.provideCompletionItems(doc, pos)) as CompletionItem[]; - // 値が空文字になっている assert.strictEqual(items[0].label, "Z - "); assert.strictEqual((items[0].documentation as any).value, "**Z**\n\n"); }); diff --git a/test/diagnostic.inference.test.ts b/test/diagnostic.inference.test.ts new file mode 100644 index 0000000..2192e5d --- /dev/null +++ b/test/diagnostic.inference.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from "assert"; + +const executeCommand = jest.fn(); + +const getMessageValueForKey = jest.fn(); +const getAllPropertyKeys = jest.fn(); +jest.mock("../src/utils", () => ({ + __esModule: true, + getMessageValueForKey, + getAllPropertyKeys, +})); + +jest.mock("../src/outputChannel", () => ({ + __esModule: true, + outputChannel: { appendLine: jest.fn(), clear: jest.fn() }, +})); + +jest.mock("vscode", () => ({ + __esModule: true, + workspace: { getConfiguration: jest.fn(), openTextDocument: jest.fn() }, + commands: { executeCommand }, + Diagnostic: class { + constructor( + public range: any, + public message: string, + public severity: any + ) {} + }, + DiagnosticSeverity: { Error: 0 }, + 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 { DiagnosticCollection, TextDocument } from "vscode"; +import { validatePlaceholders } from "../src/diagnostic"; + +describe("validatePlaceholders inference mode", () => { + let seen: any[][]; + let doc: TextDocument; + let text: string; + + beforeEach(() => { + jest.clearAllMocks(); + seen = []; + text = ""; + doc = { + languageId: "java", + getText: () => text, + positionAt: (offset: number) => ({ + line: 0, + character: offset, + translate: (_l: number, c: number) => ({ line: 0, character: offset + c }), + }), + uri: { fsPath: "/fake/Doc.java" }, + } as any; + + getAllPropertyKeys.mockReturnValue(["MSG_OK", "MSG_TWO"]); + executeCommand.mockResolvedValue([]); + }); + + it("infers message method and helper arg count when settings are empty", async () => { + text = [ + `appLogger.warn("MSG_OK", buildArgs(requestUri));`, + `appLogger.warn("MSG_TWO", createLogParams(a, b));`, + ].join("\n"); + getMessageValueForKey + .mockResolvedValueOnce("Hi {0}") + .mockResolvedValueOnce("Hi {0} {1}"); + + const collection = { + set: (_uri: any, diags: any[]) => seen.push(diags), + } as unknown as DiagnosticCollection; + await validatePlaceholders(doc, collection); + + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("reports mismatch when inferred helper arg count differs from placeholders", async () => { + text = `appLogger.warn("MSG_TWO", buildArgs(a));`; + getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); + + const collection = { + set: (_uri: any, diags: any[]) => seen.push(diags), + } as unknown as DiagnosticCollection; + await validatePlaceholders(doc, collection); + + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 1); + assert.ok(seen[0][0].message.includes("Placeholder count (2)")); + }); +}); diff --git a/test/diagnostic.test.ts b/test/diagnostic.test.ts index c39bd68..7874c73 100644 --- a/test/diagnostic.test.ts +++ b/test/diagnostic.test.ts @@ -14,7 +14,19 @@ jest.mock("../src/outputChannel", () => ({ // ——— getMessageValueForKey をモック const getMessageValueForKey = jest.fn(); -jest.mock("../src/utils", () => ({ __esModule: true, getMessageValueForKey })); +const getAllPropertyKeys = jest.fn(); +jest.mock("../src/utils", () => ({ + __esModule: true, + getMessageValueForKey, + getAllPropertyKeys, +})); + +const inferMethodPatterns = jest.fn(); +jest.mock("../src/inference", () => ({ + __esModule: true, + inferMethodPatterns, + parseCallLikeArg: jest.requireActual("../src/inference").parseCallLikeArg, +})); // ——— vscode API モック jest.mock("vscode", () => ({ @@ -87,12 +99,12 @@ describe("validatePlaceholders", () => { let text: string; let offset: number; let patterns: string[]; - let argBuilderPatterns: Array<{ pattern: string; argCount: number }>; beforeEach(() => { jest.clearAllMocks(); seen = []; - argBuilderPatterns = []; + getAllPropertyKeys.mockReturnValue([]); + inferMethodPatterns.mockImplementation(() => patterns ?? []); doc = { languageId: "java", @@ -110,18 +122,6 @@ describe("validatePlaceholders", () => { set: (_uri: any, diags: any[]) => seen.push(diags), } as any; - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ - get: (key: string, def: any) => { - 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") { @@ -1055,10 +1055,10 @@ describe("validatePlaceholders", () => { }); // ===== argBuilderPatterns テスト ===== + // NOTE: 設定項目は削除済みだが、同等の引数数検証を回帰テストとして維持する。 - it("argBuilderPattern にマッチするメソッド呼び出しは設定された argCount で検証されること", async () => { + it("argBuilderPattern にマッチするメソッド呼び出し相当は引数数推論で検証されること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", buildArgs(requestUri))`; getMessageValueForKey.mockResolvedValue("Hi {0}"); @@ -1067,9 +1067,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("argBuilderPattern にマッチするが argCount がプレースホルダー数と不一致の場合はエラーになること", async () => { + it("argBuilderPattern にマッチする相当呼び出しで引数数不一致はエラーになること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", buildArgs(requestUri))`; getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); @@ -1079,9 +1078,163 @@ describe("validatePlaceholders", () => { expect(seen[0][0].message).toMatch(/Placeholder count.*argument count/); }); - it("argBuilderPattern が修飾付き呼び出し(Utils.buildArgs)にマッチすること", async () => { + it("Object[] を返すヘルパーを varargs に渡した場合は診断されないこと", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(requestUri)); + + private Object[] buildOperatorArgs(String requestUri) { + return new Object[] { requestUri, DbUserType.OPERATOR.name() }; + } + `; + getMessageValueForKey.mockResolvedValue( + "Context type [{1}] was set. (URI = [{0}])" + ); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("ヘルパーメソッド引数なし + 呼び出し引数なし(Object[]返却)は診断されないこと", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs()); + + private Object[] buildOperatorArgs() { + return new Object[] { requestUri, DbUserType.OPERATOR.name() }; + } + `; + getMessageValueForKey.mockResolvedValue( + "Context type [{1}] was set. (URI = [{0}])" + ); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + it("ヘルパーメソッド引数なし + 呼び出し引数ありは不一致診断になること", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(requestUri)); + + private Object[] buildOperatorArgs() { + return new Object[] { requestUri, DbUserType.OPERATOR.name() }; + } + `; + getMessageValueForKey.mockResolvedValue( + "Context type [{1}] was set. (URI = [{0}])" + ); + + 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 = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs()); + + private Object[] buildOperatorArgs(String requestUri) { + return new Object[] { requestUri, DbUserType.OPERATOR.name() }; + } + `; + getMessageValueForKey.mockResolvedValue( + "Context type [{1}] was set. (URI = [{0}])" + ); + + 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 = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(requestUri)); + + private String buildOperatorArgs(String requestUri) { + return requestUri; + } + `; + getMessageValueForKey.mockResolvedValue("Context type [{1}] was set. (URI = [{0}])"); + + 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("複雑な Object[] return(コメント・引用符・ジェネリクス・匿名クラス)でも要素数推論できること", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(mapArg, marker, textArg)); + + private Object[] buildOperatorArgs( + java.util.Map> mapArg, + char marker, + String textArg + ) { + return new Object[] { + mapArg.get("k"), // line comment + /* block comment */ 'x', + "a\\\"b", + arr[0], + helper(one, two), + new Object() { public String toString() { return "v"; } } + }; + } + `; + getMessageValueForKey.mockResolvedValue("A{0}B{1}C{2}D{3}E{4}F{5}"); + + await validatePlaceholders(doc, collection); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].length, 0); + }); + + + it("Object[] 戻り値でも return が new でない場合は配列推論せず不一致診断になること", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(requestUri)); + + private Object[] buildOperatorArgs(String requestUri) { + Object[] arr = new Object[] { requestUri, opType }; + return arr; + } + `; + getMessageValueForKey.mockResolvedValue("Context type [{1}] was set. (URI = [{0}])"); + + 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("new Object[2] のような配列生成式は初期化子推論せず不一致診断になること", async () => { + patterns = ["infrastructureLogger.log"]; + text = ` + infrastructureLogger.log("PLF1031", buildOperatorArgs(requestUri)); + + private Object[] buildOperatorArgs(String requestUri) { + return new Object[2]; + } + `; + getMessageValueForKey.mockResolvedValue("Context type [{1}] was set. (URI = [{0}])"); + + 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("修飾付き呼び出し(Utils.buildArgs)も引数数推論で扱えること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 2 }]; text = `log("MSG", Utils.buildArgs(a, b))`; getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); @@ -1090,9 +1243,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("argBuilderPattern に未登録のメソッド呼び出しは既存ロジック(argCount=1)にフォールスルーすること", async () => { + it("未登録メソッド相当でも引数数推論で検証されること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", someOtherMethod(x))`; getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); @@ -1102,24 +1254,18 @@ describe("validatePlaceholders", () => { expect(seen[0][0].message).toMatch(/Placeholder count.*argument count/); }); - it("argBuilderPatterns が空の場合は既存動作が維持されること", async () => { + it("buildArgs(x) は単一引数として一致すること", async () => { patterns = ["log"]; - argBuilderPatterns = []; text = `log("MSG", buildArgs(x))`; getMessageValueForKey.mockResolvedValue("Hi {0}"); await validatePlaceholders(doc, collection); - // buildArgs(x) は単一引数扱い → actualArgCount=1, expectedArgCount=1 → 一致 assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].length, 0); }); - it("複数の argBuilderPatterns から正しいパターンがマッチすること", async () => { + it("複数引数の helper 呼び出しを正しく検証できること", async () => { patterns = ["log"]; - argBuilderPatterns = [ - { pattern: "buildArgs", argCount: 1 }, - { pattern: "createLogParams", argCount: 2 }, - ]; text = `log("MSG", createLogParams(a, b))`; getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); @@ -1128,9 +1274,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("this.buildArgs() のような this 修飾付き呼び出しにマッチすること", async () => { + it("this.buildArgs() のような this 修飾付き呼び出しを扱えること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", this.buildArgs(requestUri))`; getMessageValueForKey.mockResolvedValue("Hi {0}"); @@ -1139,9 +1284,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("深いパッケージ修飾(a.b.c.buildArgs)にマッチすること", async () => { + it("深いパッケージ修飾(a.b.c.buildArgs)でも扱えること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 3 }]; text = `log("MSG", com.example.Utils.buildArgs(a, b, c))`; getMessageValueForKey.mockResolvedValue("Hi {0} {1} {2}"); @@ -1150,21 +1294,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("パターン 'build' が 'buildArgs' に部分一致しないこと", async () => { + it("emptyArgs() は 0 引数として扱われること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "build", argCount: 2 }]; - text = `log("MSG", buildArgs(x))`; - getMessageValueForKey.mockResolvedValue("Hi {0}"); - - await validatePlaceholders(doc, collection); - // "build" は "buildArgs" にマッチしない → フォールスルー → actualArgCount=1 - assert.strictEqual(seen.length, 1); - assert.strictEqual(seen[0].length, 0); - }); - - it("argCount: 0 の場合はプレースホルダーなしのメッセージと一致すること", async () => { - patterns = ["log"]; - argBuilderPatterns = [{ pattern: "emptyArgs", argCount: 0 }]; text = `log("MSG", emptyArgs())`; getMessageValueForKey.mockResolvedValue("Hello"); @@ -1173,9 +1304,8 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("argCount: 0 でプレースホルダーがある場合はエラーになること", async () => { + it("emptyArgs() でプレースホルダーがある場合はエラーになること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "emptyArgs", argCount: 0 }]; text = `log("MSG", emptyArgs())`; getMessageValueForKey.mockResolvedValue("Hi {0}"); @@ -1185,36 +1315,28 @@ describe("validatePlaceholders", () => { expect(seen[0][0].message).toMatch(/Placeholder count.*argument count/); }); - it("argBuilderPattern + 例外引数(ex)が末尾にある場合は ex が除外されること", async () => { + it("helper + 例外引数(ex)が末尾にある場合は ex が除外されること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", buildArgs(requestUri), ex)`; getMessageValueForKey.mockResolvedValue("Hi {0}"); await validatePlaceholders(doc, collection); - // args = ["buildArgs(requestUri)", "ex"] - // args.length > 1 なので argBuilderPattern 分岐には入らない - // 末尾 ex が除外ロジックで処理され、残り1個で一致 assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].length, 0); }); - it("argBuilderPattern + locale 引数が末尾にある場合は locale が除外されること", async () => { + it("helper + locale 引数が末尾にある場合は locale が除外されること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", buildArgs(requestUri), Locale.JAPAN)`; getMessageValueForKey.mockResolvedValue("Hi {0}"); await validatePlaceholders(doc, collection); - // locale が除外 → args = ["buildArgs(requestUri)"] - // args.length === 1 → argBuilderPattern マッチ → argCount=1 → 一致 assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].length, 0); }); - it("メソッド名の前にスペースがある場合(buildArgs (x))にもマッチすること", async () => { + it("メソッド名の前にスペースがある場合(buildArgs (x))も扱えること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 1 }]; text = `log("MSG", buildArgs (requestUri))`; getMessageValueForKey.mockResolvedValue("Hi {0}"); @@ -1223,16 +1345,12 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen[0].length, 0); }); - it("同一ファイルに複数の argBuilderPattern 対象呼び出しがある場合、それぞれ検証されること", async () => { + it("同一ファイルに複数 helper 呼び出しがある場合、それぞれ検証されること", async () => { patterns = ["log"]; - argBuilderPatterns = [ - { pattern: "buildArgs", argCount: 1 }, - { pattern: "createParams", argCount: 2 }, - ]; text = `log("MSG1", buildArgs(a)); log("MSG2", createParams(a, b))`; getMessageValueForKey - .mockResolvedValueOnce("Hi {0}") // MSG1: 1個 → buildArgs argCount=1 → OK - .mockResolvedValueOnce("Hi {0} {1}"); // MSG2: 2個 → createParams argCount=2 → OK + .mockResolvedValueOnce("Hi {0}") + .mockResolvedValueOnce("Hi {0} {1}"); await validatePlaceholders(doc, collection); assert.strictEqual(seen.length, 1); @@ -1241,14 +1359,10 @@ describe("validatePlaceholders", () => { it("同一ファイルで一方が一致・他方が不一致の場合、不一致のみエラーになること", async () => { patterns = ["log"]; - argBuilderPatterns = [ - { pattern: "buildArgs", argCount: 1 }, - { pattern: "createParams", argCount: 2 }, - ]; text = `log("MSG1", buildArgs(a)); log("MSG2", createParams(a, b))`; getMessageValueForKey - .mockResolvedValueOnce("Hi {0}") // MSG1: OK - .mockResolvedValueOnce("Hi {0} {1} {2}"); // MSG2: 3個 vs argCount=2 → NG + .mockResolvedValueOnce("Hi {0}") + .mockResolvedValueOnce("Hi {0} {1} {2}"); await validatePlaceholders(doc, collection); assert.strictEqual(seen.length, 1); @@ -1256,22 +1370,18 @@ describe("validatePlaceholders", () => { expect(seen[0][0].message).toMatch(/Placeholder count \(3\).*argument count \(2\)/); }); - it("配列リテラルが優先され、argBuilderPattern は適用されないこと", async () => { + it("配列リテラルが優先され、helper 呼び出し推論は適用されないこと", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "buildArgs", argCount: 3 }]; - // new Object[] { ... } は配列リテラルとして先に処理される text = `log("MSG", new Object[] { "A", "B" })`; getMessageValueForKey.mockResolvedValue("Hi {0} {1}"); await validatePlaceholders(doc, collection); - // 配列リテラルとして要素2個 → プレースホルダー2個 → 一致 assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].length, 0); }); - it("パターン名に正規表現の特殊文字が含まれていても正しくエスケープされること", async () => { + it("メソッド名に特殊文字($)が含まれていても引数数推論できること", async () => { patterns = ["log"]; - argBuilderPatterns = [{ pattern: "build$Args", argCount: 1 }]; text = `log("MSG", build$Args(x))`; getMessageValueForKey.mockResolvedValue("Hi {0}"); @@ -1279,4 +1389,5 @@ describe("validatePlaceholders", () => { assert.strictEqual(seen.length, 1); assert.strictEqual(seen[0].length, 0); }); + }); diff --git a/test/extension.cacheIndexing.test.ts b/test/extension.cacheIndexing.test.ts index 6420436..6a8615b 100644 --- a/test/extension.cacheIndexing.test.ts +++ b/test/extension.cacheIndexing.test.ts @@ -498,7 +498,7 @@ describe("activate cache/index behavior (additional cases)", () => { ); }); - it("抽出パターン設定変更で全 Java 強制再検証する", async () => { + it("削除済み抽出設定の変更は再検証トリガーにならない", async () => { findFiles.mockResolvedValueOnce([{ fsPath: "/src/A.java" }]); openTextDocument.mockResolvedValue( makeJavaDoc("/src/A.java", () => "class A {}") @@ -509,17 +509,12 @@ describe("activate cache/index behavior (additional cases)", () => { onConfigChangeCb?.({ affectsConfiguration: (key: string) => - key === "java-message-key-navigator.messageKeyExtractionPatterns", + key === "java-message-key-navigator.legacyRemovedSettingA", }); await flushMicrotasks(12); - expect(findFiles).toHaveBeenCalledWith( - JAVA_INCLUDE_PATTERN, - JAVA_EXCLUDE_PATTERN - ); - expect((PropertyValidator.validateProperties as jest.Mock).mock.calls.length).toBe( - 1 - ); + expect(findFiles).not.toHaveBeenCalled(); + expect((PropertyValidator.validateProperties as jest.Mock).mock.calls.length).toBe(0); }); it("無関係な設定変更では再検証しない", async () => { @@ -859,7 +854,7 @@ describe("activate cache/index behavior (additional cases)", () => { } ); - it("argBuilderPatterns 設定変更で全 Java 強制再検証する", async () => { + it("削除済み argBuilder 設定の変更は再検証トリガーにならない", async () => { findFiles.mockResolvedValueOnce([{ fsPath: "/src/A.java" }]); openTextDocument.mockResolvedValue( makeJavaDoc("/src/A.java", () => "class A {}") @@ -870,17 +865,12 @@ describe("activate cache/index behavior (additional cases)", () => { onConfigChangeCb?.({ affectsConfiguration: (key: string) => - key === "java-message-key-navigator.argBuilderPatterns", + key === "java-message-key-navigator.legacyRemovedSettingB", }); await flushMicrotasks(12); - expect(findFiles).toHaveBeenCalledWith( - JAVA_INCLUDE_PATTERN, - JAVA_EXCLUDE_PATTERN - ); - expect( - (PropertyValidator.validateProperties as jest.Mock).mock.calls.length - ).toBeGreaterThanOrEqual(1); + expect(findFiles).not.toHaveBeenCalled(); + expect((PropertyValidator.validateProperties as jest.Mock).mock.calls.length).toBe(0); }); it("activate 時に propertyFileGlobs の FileSystemWatcher が作成される", async () => { @@ -909,7 +899,12 @@ describe("activate cache/index behavior (additional cases)", () => { ), }); createFileSystemWatcher.mockImplementation((pattern: string) => ({ - onDidCreate: jest.fn(() => disposable()), + onDidCreate: jest.fn((fn: any) => { + if (pattern === JAVA_INCLUDE_PATTERN) { + watcherCreateCb = fn; + } + return disposable(); + }), onDidChange: jest.fn((fn: any) => { if (pattern !== JAVA_INCLUDE_PATTERN) { propChangeCb = fn; @@ -923,13 +918,16 @@ describe("activate cache/index behavior (additional cases)", () => { })); mockWorkspace.workspaceFolders = [{ uri: { fsPath: "/project" } }]; - findFiles.mockResolvedValueOnce([{ fsPath: "/project/src/Foo.java" }]); + findFiles.mockResolvedValue([{ fsPath: "/project/src/Foo.java" }]); openTextDocument.mockResolvedValue( makeJavaDoc("/project/src/Foo.java", () => "class Foo {}") ); await activate(context); await flushMicrotasks(12); + assert.ok(watcherCreateCb, "Java watcher の onDidCreate が登録されていない"); + watcherCreateCb?.({ fsPath: "/project/src/Foo.java" }); + await flushMicrotasks(12); (PropertyValidator.validateProperties as jest.Mock).mockClear(); (diagnostic.validatePlaceholders as jest.Mock).mockClear(); diff --git a/test/inference.test.ts b/test/inference.test.ts new file mode 100644 index 0000000..4bbc246 --- /dev/null +++ b/test/inference.test.ts @@ -0,0 +1,191 @@ +import { strict as assert } from "assert"; +import { + inferAnnotationRegexSources, + inferAnnotationTargets, + inferMethodPatterns, + matchesInferredCompletionContext, + parseCallLikeArg, +} from "../src/inference"; + +describe("inference", () => { + it("inferMethodPatterns extracts literal-first-arg method paths and ignores comments", () => { + const source = [ + `// infrastructureLogger.log("COMMENT_ONLY")`, + `/* appLogger.warn("BLOCK_ONLY")`, + ` continued */`, + `String s = "text with appLogger.warn(\\"STR\\")";`, + `infrastructureLogger.log("MSG.OK");`, + `infrastructureLogger.log("MSG.MISS");`, + `appLogger.warn('MSG.OK');`, + `foo(bar, "ignored-second-arg");`, + `messageSource.getMessage("MSG.OK");`, + ].join("\n"); + + const patterns = inferMethodPatterns(source, new Set(["MSG.OK"])); + assert.deepStrictEqual( + new Set(patterns), + new Set(["appLogger.warn", "messageSource.getMessage"]) + ); + }); + + it("inferMethodPatterns handles escaped literal characters", () => { + const source = `a.log("MSG\\\\.A\\n\\r\\t"); b.log("MSG\\\\.A\\n\\r\\t");`; + const patterns = inferMethodPatterns(source, new Set(["MSG\\.A\n\r\t"])); + assert.deepStrictEqual(patterns, ["a.log", "b.log"]); + }); + + it("inferMethodPatterns handles zero-arg calls, malformed paths, and nested structures", () => { + const source = [ + `fn();`, + `.bad("X");`, + `arr[idx].warn("K1");`, + `ok.warn("K1", new Object[] { a, b }, map.get("x"));`, + ].join("\n"); + const patterns = inferMethodPatterns(source, new Set(["K1"])); + assert.ok(patterns.includes("ok.warn")); + }); + + it("inferAnnotationTargets and regex sources infer only non-ambiguous annotation attrs", () => { + const source = [ + `@pkg.LogStartEnd(start = "MSG_START", end = "MSG_END", exception = "MISS")`, + `@LogStartEnd(start = "MSG_START")`, + `@Other(flag = true)`, + `@Bad(start = unknownVar)`, + ].join("\n"); + + const keys = new Set(["MSG_START", "MSG_END"]); + const targets = inferAnnotationTargets(source, keys); + assert.deepStrictEqual( + targets, + new Set(["LogStartEnd#start", "LogStartEnd#end"]) + ); + + const regexes = inferAnnotationRegexSources(source, keys); + assert.ok( + regexes.some((r) => new RegExp(r, "g").test(`@LogStartEnd(start="MSG_START")`)) + ); + assert.ok( + regexes.some((r) => new RegExp(r, "g").test(`@LogStartEnd(foo=1, end="MSG_END")`)) + ); + assert.strictEqual(regexes.some((r) => r.includes("exception")), false); + }); + + it("inferAnnotationTargets skips malformed annotation forms", () => { + const source = [ + `@1Bad(start="A")`, + `@NoArgs`, + `@Broken(start="A"`, + `@Ann(noEq "A")`, + `@Ann(bad-name="A")`, + `@Ann('x' = "A")`, + `@Ann(text='A')`, + `@Ann(name(arr[0])="A")`, + `@Ann(name{a}="A")`, + `@Ann(data = "A", arr = { "B", "C" })`, + ].join("\n"); + const targets = inferAnnotationTargets(source, new Set(["A"])); + assert.ok(targets.has("Ann#data")); + assert.strictEqual(targets.has("Ann#arr"), false); + }); + + it("parseCallLikeArg parses method call and arg count", () => { + assert.deepStrictEqual(parseCallLikeArg("createLogParams(a, b)"), { + methodName: "createLogParams", + argCount: 2, + }); + assert.deepStrictEqual(parseCallLikeArg("Utils.buildArgs ( req )"), { + methodName: "buildArgs", + argCount: 1, + }); + assert.deepStrictEqual(parseCallLikeArg("emptyArgs()"), { + methodName: "emptyArgs", + argCount: 0, + }); + }); + + it("parseCallLikeArg rejects non-call and malformed expressions", () => { + assert.strictEqual(parseCallLikeArg("justVariable"), null); + assert.strictEqual(parseCallLikeArg("(a, b)"), null); + assert.strictEqual(parseCallLikeArg("buildArgs(a)) + suffix"), null); + assert.strictEqual(parseCallLikeArg(".buildArgs(a)"), null); + assert.strictEqual(parseCallLikeArg("buildArgs(a))"), null); + assert.deepStrictEqual(parseCallLikeArg("f(,a)"), { + methodName: "f", + argCount: 1, + }); + assert.strictEqual(parseCallLikeArg("a.b.c."), null); + assert.deepStrictEqual(parseCallLikeArg("buildArgs(a, b, c)"), { + methodName: "buildArgs", + argCount: 3, + }); + }); + + it("matchesInferredCompletionContext matches method invocation context", () => { + assert.strictEqual( + matchesInferredCompletionContext( + `infrastructureLogger.log("`, + ["infrastructureLogger.log"], + new Set() + ), + true + ); + assert.strictEqual( + matchesInferredCompletionContext(`foo.log("`, ["appLogger.log"], new Set()), + true + ); + }); + + it("matchesInferredCompletionContext matches annotation assignment context", () => { + assert.strictEqual( + matchesInferredCompletionContext( + `@LogStartEnd(start="`, + [], + new Set(["LogStartEnd#start"]) + ), + true + ); + assert.strictEqual( + matchesInferredCompletionContext( + `@a.b.LogStartEnd(end = "`, + [], + new Set(["LogStartEnd#end"]) + ), + true + ); + }); + + it("matchesInferredCompletionContext returns false for unsupported contexts", () => { + assert.strictEqual( + matchesInferredCompletionContext(`plain text`, ["a.log"], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`foo("`, [], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`start="`, [], new Set(["A#start"])), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`@1Bad(start="`, [], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`@LogStartEnd(="`, [], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`@Ann(1a="`, [], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`("`, ["x.y"], new Set()), + false + ); + assert.strictEqual( + matchesInferredCompletionContext(`foo "`, ["x.y"], new Set()), + false + ); + }); +}); diff --git a/test/utils.inference-patterns.test.ts b/test/utils.inference-patterns.test.ts new file mode 100644 index 0000000..f375d29 --- /dev/null +++ b/test/utils.inference-patterns.test.ts @@ -0,0 +1,87 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { strict as assert } from "assert"; +import * as vscode from "vscode"; + +jest.mock("vscode", () => ({ + __esModule: true, + workspace: { + getConfiguration: jest.fn(), + findFiles: jest.fn(), + openTextDocument: jest.fn(), + }, + window: { + createOutputChannel: jest.fn(), + showErrorMessage: jest.fn(), + showWarningMessage: jest.fn(), + showInformationMessage: jest.fn(), + showTextDocument: jest.fn(), + }, + Uri: { file: (fsPath: string) => ({ fsPath }) }, + Position: class { + constructor(public line: number, public character: number) {} + }, + Range: class { + constructor(public start: any, public end: any) {} + }, + Selection: class { + constructor(public start: any, public end: any) {} + }, + ViewColumn: { One: 1 }, +})); + +jest.mock("../src/outputChannel", () => ({ + __esModule: true, + outputChannel: { appendLine: jest.fn(), clear: jest.fn() }, +})); + +import { getCustomPatterns, loadPropertyDefinitions } from "../src/utils"; + +describe("getCustomPatterns inference", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("infers method and annotation patterns when both settings are empty", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "infer-patterns-")); + const propFile = path.join(tmpDir, "messages.properties"); + fs.writeFileSync(propFile, "MSG_START=a\nMSG_END=b\n"); + + (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ + get: (section: string, def: any) => { + if (section === "propertyFileGlobs") { + return [`${tmpDir}/*.properties`]; + } + return def; + }, + }); + (vscode.workspace.findFiles as jest.Mock).mockResolvedValue([ + vscode.Uri.file(propFile), + ]); + + await loadPropertyDefinitions(); + const text = [ + `appLogger.warn("MSG_START");`, + `@LogStartEnd(start = "MSG_END")`, + ].join("\n"); + + const regexes = getCustomPatterns(text); + assert.ok(regexes.some((re) => re.test(`appLogger.warn("MSG_START")`))); + assert.ok( + regexes.some((re) => re.test(`@LogStartEnd(start = "MSG_END")`)), + `inferred annotation regexes: ${regexes.map((r) => r.source).join(", ")}` + ); + assert.ok(regexes.some((re) => re.test(`messageSource.getMessage("K")`))); + }); + + it("falls back to messageSource pattern when inference source is insufficient", () => { + (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue({ + get: (_section: string, def: any) => def, + }); + + const regexes = getCustomPatterns(""); + assert.strictEqual(regexes.length, 1); + assert.ok(regexes[0].test(`messageSource.getMessage("X")`)); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 139469e..53842c1 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -123,27 +123,10 @@ describe("utils.ts", () => { }); describe("getCustomPatterns", () => { - let cfg: jest.SpyInstance; - beforeEach(() => { - cfg = jest.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ - get: (section: string) => { - if (section === "messageKeyExtractionPatterns") - return ["foo.bar", "baz"]; - if (section === "annotationKeyExtractionPatterns") - return ['@Ann\\("([^"]+)"\\)']; - return []; - }, - } as any); - }); - afterEach(() => cfg.mockRestore()); - - it("呼び出しパターンとアノテーションパターンの正規表現を生成する", () => { + it("入力テキストがない場合は messageSource.getMessage のみを返す", () => { const patterns = getCustomPatterns(); - assert.strictEqual(patterns.length, 4); - assert.ok(patterns[0].test('foo.bar("x")')); - assert.ok(patterns[1].test('baz("y")')); - assert.ok(patterns[2].test('messageSource.getMessage("z")')); - assert.ok(patterns[3].test('@Ann("v")')); + assert.strictEqual(patterns.length, 1); + assert.ok(patterns[0].test('messageSource.getMessage("z")')); }); });