diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ASTExpressionHelper.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ASTExpressionHelper.java new file mode 100644 index 0000000000..b8189b724b --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ASTExpressionHelper.java @@ -0,0 +1,243 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Assignment; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IBinding; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +/** + * Shared utilities for working with AST expressions. + * Provides common functionality for tracing variable bindings and extracting values. + */ +public class ASTExpressionHelper { + + /** + * AST visitor to find a variable declaration fragment or assignment for a given variable binding. + */ + public static class VariableFinder extends ASTVisitor { + private final org.eclipse.jdt.core.dom.IVariableBinding targetBinding; + public VariableDeclarationFragment declarationFragment; + public Assignment assignment; + + public VariableFinder(org.eclipse.jdt.core.dom.IVariableBinding targetBinding) { + this.targetBinding = targetBinding; + } + + /** + * Checks if two bindings represent the same variable/field. + * Uses == first (fastest), then falls back to getKey() comparison. + */ + private boolean isSameBinding(IBinding binding1, IBinding binding2) { + if (binding1 == binding2) { + return true; + } + if (binding1 == null || binding2 == null) { + return false; + } + // Fallback: compare by binding key + try { + return binding1.getKey().equals(binding2.getKey()); + } catch (Exception e) { + return false; + } + } + + @Override + public boolean visit(VariableDeclarationFragment node) { + IBinding nodeBinding = node.resolveBinding(); + if (isSameBinding(nodeBinding, targetBinding)) { + declarationFragment = node; + // Continue visiting to also check for assignments (in case initializer is null) + } + return true; + } + + @Override + public boolean visit(Assignment node) { + Expression leftHandSide = node.getLeftHandSide(); + IBinding lhsBinding = null; + + if (leftHandSide instanceof org.eclipse.jdt.core.dom.SimpleName) { + org.eclipse.jdt.core.dom.SimpleName name = (org.eclipse.jdt.core.dom.SimpleName) leftHandSide; + lhsBinding = name.resolveBinding(); + } else if (leftHandSide instanceof org.eclipse.jdt.core.dom.FieldAccess) { + org.eclipse.jdt.core.dom.FieldAccess fieldAccess = (org.eclipse.jdt.core.dom.FieldAccess) leftHandSide; + lhsBinding = fieldAccess.getName().resolveBinding(); + } + + if (isSameBinding(lhsBinding, targetBinding)) { + assignment = node; + return false; // Stop visiting once we find the assignment + } + + return true; + } + } + + /** + * Checks if a MethodInvocation is ResourceBundle.getBundle(). + */ + private static boolean isResourceBundleGetBundle(MethodInvocation invocation) { + if (invocation == null) { + return false; + } + IMethodBinding binding = invocation.resolveMethodBinding(); + if (binding == null || !"getBundle".equals(binding.getName())) { + return false; + } + // Check if it's ResourceBundle.getBundle() by checking the declaring class + org.eclipse.jdt.core.dom.ITypeBinding declaringClass = binding.getDeclaringClass(); + return declaringClass != null && "java.util.ResourceBundle".equals(declaringClass.getQualifiedName()); + } + + /** + * Finds a ResourceBundle.getBundle() invocation by tracing back through variable/field bindings. + * Handles variables, field access, and direct method invocations. + * + * @param bundleExpression the expression representing the bundle + * @param root the AST root node + * @return the getBundle() method invocation, or null if not found + */ + public static MethodInvocation findGetBundleInvocation(Expression bundleExpression, ASTNode root) { + if (bundleExpression == null || root == null) { + return null; + } + + // If bundleExpression is already a getBundle() call, return it + if (bundleExpression instanceof MethodInvocation invocation) { + if (isResourceBundleGetBundle(invocation)) { + return invocation; + } + } + + // If bundleExpression is a variable or field access, find where it was assigned + org.eclipse.jdt.core.dom.IVariableBinding vb = extractVariableBinding(bundleExpression); + if (vb != null) { + VariableFinder finder = new VariableFinder(vb); + root.accept(finder); + + // Check initializer + if (finder.declarationFragment != null) { + Expression initializer = finder.declarationFragment.getInitializer(); + if (initializer != null) { + // If initializer is directly a getBundle() call, return it + if (initializer instanceof MethodInvocation inv) { + if (isResourceBundleGetBundle(inv)) { + return inv; + } + } + // Otherwise, recursively search (but avoid infinite recursion by checking if it's a variable) + org.eclipse.jdt.core.dom.IVariableBinding initVb = extractVariableBinding(initializer); + if (initVb != null && initVb != vb) { + // It's a different variable, trace it recursively + MethodInvocation recursiveResult = findGetBundleInvocation(initializer, root); + if (recursiveResult != null) { + return recursiveResult; + } + } + } + } + + // Check assignment + if (finder.assignment != null) { + Expression rhs = finder.assignment.getRightHandSide(); + if (rhs != null) { + // If RHS is directly a getBundle() call, return it + if (rhs instanceof MethodInvocation inv) { + if (isResourceBundleGetBundle(inv)) { + return inv; + } + } + // Otherwise, recursively search (but avoid infinite recursion by checking if it's a variable) + org.eclipse.jdt.core.dom.IVariableBinding rhsVb = extractVariableBinding(rhs); + if (rhsVb != null && rhsVb != vb) { + // It's a different variable, trace it recursively + MethodInvocation recursiveResult = findGetBundleInvocation(rhs, root); + if (recursiveResult != null) { + return recursiveResult; + } + } + } + } + } + + return null; + } + + /** + * Extracts a variable binding from an expression. + * Handles both SimpleName (variables) and FieldAccess (fields). + * + * @param expression the expression + * @return the variable binding, or null if not a variable/field + */ + public static org.eclipse.jdt.core.dom.IVariableBinding extractVariableBinding(Expression expression) { + if (expression instanceof org.eclipse.jdt.core.dom.SimpleName name) { + IBinding binding = name.resolveBinding(); + if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding vb) { + return vb; + } + } else if (expression instanceof org.eclipse.jdt.core.dom.FieldAccess fieldAccess) { + IBinding binding = fieldAccess.getName().resolveBinding(); + if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding vb) { + return vb; + } + } + return null; + } + + /** + * Extracts a string value from an expression. + * Handles string literals and traces back variables. + * + * @param expression the expression + * @param root the AST root node + * @return the string value, or null if not found + */ + public static String extractStringFromExpression(Expression expression, ASTNode root) { + if (expression == null) { + return null; + } + + // Direct string literal + if (expression instanceof StringLiteral stringLiteral) { + return stringLiteral.getLiteralValue(); + } + + // Variable reference: trace back + org.eclipse.jdt.core.dom.IVariableBinding vb = extractVariableBinding(expression); + if (vb != null) { + VariableFinder finder = new VariableFinder(vb); + root.accept(finder); + + if (finder.declarationFragment != null) { + Expression initializer = finder.declarationFragment.getInitializer(); + return extractStringFromExpression(initializer, root); + } + + if (finder.assignment != null) { + Expression rhs = finder.assignment.getRightHandSide(); + return extractStringFromExpression(rhs, root); + } + } + + return null; + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ProjectClassLoader.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ProjectClassLoader.java new file mode 100644 index 0000000000..12511761d1 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ProjectClassLoader.java @@ -0,0 +1,167 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jdt.core.IClasspathEntry; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; + +/** + * Creates a ClassLoader for a Java project that can load resources from the project's classpath. + * This allows using ResourceBundle.getBundle() API which handles locale fallback automatically. + */ +public class ProjectClassLoader { + + /** + * Creates a ClassLoader for the given Java project. + * The classloader includes: + * - Output folders (compiled classes/resources) + * - Source folders (for resources in source) + * - Library entries (JARs) + * + * @param javaProject the Java project + * @param monitor the progress monitor + * @return a ClassLoader that can load resources from the project's classpath, or null if creation fails + */ + public static ClassLoader createClassLoader(IJavaProject javaProject, IProgressMonitor monitor) { + if (javaProject == null) { + return null; + } + + try { + IProject project = javaProject.getProject(); + if (project == null || !project.exists()) { + return null; + } + + List urls = new ArrayList<>(); + + // Get output location (compiled classes/resources) + IPath outputPath = javaProject.getOutputLocation(); + if (outputPath != null) { + IPath relativeOutputPath = outputPath.makeRelativeTo(project.getFullPath()); + IFolder outputFolder = project.getFolder(relativeOutputPath); + if (outputFolder.exists()) { + IPath location = outputFolder.getLocation(); + if (location != null) { + URL url = location.toFile().toURI().toURL(); + urls.add(url); + } + } + } + + // Get resolved classpath entries (includes source folders and libraries) + IClasspathEntry[] classpath = javaProject.getResolvedClasspath(true); + for (IClasspathEntry entry : classpath) { + if (monitor.isCanceled()) { + return null; + } + + IPath path = entry.getPath(); + if (path == null) { + continue; + } + + URL url = null; + int entryKind = entry.getEntryKind(); + + switch (entryKind) { + case IClasspathEntry.CPE_SOURCE: + // Source folder: use the source folder location directly + IPath relativePath = path.makeRelativeTo(project.getFullPath()); + if (!relativePath.isEmpty()) { + IFolder sourceFolder = project.getFolder(relativePath); + if (sourceFolder.exists()) { + IPath location = sourceFolder.getLocation(); + if (location != null) { + url = location.toFile().toURI().toURL(); + } + } + } + break; + + case IClasspathEntry.CPE_LIBRARY: + // Library (JAR file): use the file location + File file = path.toFile(); + if (file.exists()) { + url = file.toURI().toURL(); + } else { + // Try relative to project + IPath relativeLibPath = path.makeRelativeTo(project.getFullPath()); + IFolder libFolder = project.getFolder(relativeLibPath); + if (libFolder.exists()) { + IPath location = libFolder.getLocation(); + if (location != null) { + url = location.toFile().toURI().toURL(); + } + } + } + break; + + case IClasspathEntry.CPE_PROJECT: + // Project dependency: get its output location + IProject depProject = project.getWorkspace().getRoot().getProject(path.lastSegment()); + if (depProject != null && depProject.exists()) { + IJavaProject depJavaProject = org.eclipse.jdt.core.JavaCore.create(depProject); + if (depJavaProject != null && depJavaProject.exists()) { + IPath depOutputPath = depJavaProject.getOutputLocation(); + if (depOutputPath != null) { + IPath relativeDepOutput = depOutputPath.makeRelativeTo(depProject.getFullPath()); + IFolder depOutputFolder = depProject.getFolder(relativeDepOutput); + if (depOutputFolder.exists()) { + IPath location = depOutputFolder.getLocation(); + if (location != null) { + url = location.toFile().toURI().toURL(); + } + } + } + } + } + break; + + default: + // Skip containers and other entry types + break; + } + + if (url != null) { + urls.add(url); + } + } + + if (urls.isEmpty()) { + return null; + } + + // Create URLClassLoader with parent classloader (to access system classes) + URL[] urlArray = urls.toArray(new URL[urls.size()]); + return new URLClassLoader(urlArray, ClassLoader.getSystemClassLoader()); + + } catch (MalformedURLException | CoreException e) { + JavaLanguageServerPlugin.logException("Error creating classloader for project: " + javaProject.getElementName(), e); + return null; + } + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleCompletionProposal.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleCompletionProposal.java new file mode 100644 index 0000000000..e3ad5e2c5b --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleCompletionProposal.java @@ -0,0 +1,172 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.ls.core.internal.CompletionUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.contentassist.CompletionProposalRequestor; +import org.eclipse.jdt.ls.core.internal.contentassist.SortTextHelper; +import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers; +import org.eclipse.jface.text.IDocument; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemDefaults; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; + +/** + * Provides completion proposals for resource bundle keys. + * Detects when completion is triggered inside ResourceBundle.getString() calls + * and provides completions based on keys found in .properties files in the project. + * + * This class orchestrates the work of several helper classes: + * - ResourceBundleContextDetector: Detects ResourceBundle context + * - ResourceBundlePropertiesFinder: Finds and processes properties files + * - ResourceBundleTextProcessor: Processes document text for completion + */ +public class ResourceBundleCompletionProposal { + + private final ResourceBundleContextDetector contextDetector; + private final ResourceBundlePropertiesFinder propertiesFinder; + private final ResourceBundleTextProcessor textProcessor; + + public ResourceBundleCompletionProposal() { + this.contextDetector = new ResourceBundleContextDetector(); + this.propertiesFinder = new ResourceBundlePropertiesFinder(); + this.textProcessor = new ResourceBundleTextProcessor(); + } + + /** + * Gets completion proposals for resource bundle keys. + * + * @param cu the compilation unit + * @param offset the offset where completion was triggered + * @param collector the completion proposal requestor + * @param monitor the progress monitor + * @return list of completion items for resource bundle keys + */ + public List getProposals(ICompilationUnit cu, int offset, CompletionProposalRequestor collector, IProgressMonitor monitor) { + if (cu == null) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + try { + // Get method invocation context to extract bundle name and locale + ResourceBundleContextDetector.ResourceBundleContext context = contextDetector.detectContext(cu, offset, monitor); + if (context == null || context.bundleName == null || context.bundleName.isEmpty()) { + return result; + } + String bundleName = context.bundleName; + String locale = context.locale; + + // Find all properties files and extract keys with their values + // Prioritize locale-specific files if locale is detected + Map keyValueMap = propertiesFinder.findResourceBundleKeys(cu.getJavaProject(), bundleName, locale, monitor); + if (keyValueMap.isEmpty()) { + return result; + } + + // Create completion items for the keys + IDocument document = JsonRpcHelpers.toDocument(cu.getBuffer()); + ResourceBundleTextProcessor.QuotePositions quotes = textProcessor.findQuotePositions(document, offset, context.invocation); + boolean insideQuotes = quotes.openingQuote() >= 0; + String prefix = textProcessor.getPrefix(document, offset, quotes); + Range range = textProcessor.calculateRange(document, offset, prefix, quotes); + + CompletionItemDefaults completionItemDefaults = collector.getCompletionItemDefaults(); + boolean useItemDefaults = shouldUseItemDefaults(range, completionItemDefaults); + + // Filter keys by prefix and create completion items + // keyValueMap already contains deduplicated keys (from LinkedHashMap) + for (Map.Entry entry : keyValueMap.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (prefix.isEmpty() || key.toLowerCase().startsWith(prefix.toLowerCase())) { + CompletionItem item = createCompletionItem(key, value, range, useItemDefaults, completionItemDefaults, insideQuotes); + result.add(item); + } + } + } catch (Exception e) { + JavaLanguageServerPlugin.logException("Error providing resource bundle key completions", e); + } + + return result; + } + + /** + * Determines whether to use completion item defaults for the edit range. + * Uses item defaults if the client supports it and the calculated range matches + * the default edit range from the completion item defaults. + * + * @param range the calculated range for the completion + * @param completionItemDefaults the completion item defaults from the collector + * @return true if item defaults should be used, false otherwise + */ + private boolean shouldUseItemDefaults(Range range, CompletionItemDefaults completionItemDefaults) { + return JavaLanguageServerPlugin.getPreferencesManager().getClientPreferences() + .isCompletionListItemDefaultsPropertySupport("editRange") + && completionItemDefaults != null + && completionItemDefaults.getEditRange() != null + && completionItemDefaults.getEditRange().getLeft() != null + && range.equals(completionItemDefaults.getEditRange().getLeft()); + } + + /** + * Creates a completion item for a resource bundle key. + * @param insideQuotes true if we're inside quotes (insert just the key), false if outside quotes (insert "key") + */ + private CompletionItem createCompletionItem(String key, String value, Range range, boolean useItemDefaults, CompletionItemDefaults completionItemDefaults, boolean insideQuotes) { + CompletionItem item = new CompletionItem(); + item.setLabel(key); + item.setKind(CompletionItemKind.Property); + // Use very high relevance to get lowest sort text (highest priority) + // Regular completions use relevance * 16 + offsets (typically < 1,000,000) + // Using a value close to MAX_RELEVANCE_VALUE ensures resource bundle keys appear first + item.setSortText(SortTextHelper.convertRelevance(SortTextHelper.MAX_RELEVANCE_VALUE - 1000)); + item.setFilterText(key); + + // If we're not inside quotes, wrap the key in quotes + String insertText = insideQuotes ? key : "\"" + key + "\""; + + if (useItemDefaults && completionItemDefaults != null) { + item.setTextEditText(insertText); + } else { + item.setTextEdit(Either.forLeft(new TextEdit(range, insertText))); + } + + CompletionUtils.setInsertTextFormat(item, completionItemDefaults); + CompletionUtils.setInsertTextMode(item, completionItemDefaults); + + // Set the property value as documentation + if (value != null) { + // Format multiline values for markdown: replace "\n" with " \n" + String formattedValue = value.replace("\n", " \n"); + MarkupContent documentation = new MarkupContent(MarkupKind.MARKDOWN, formattedValue); + item.setDocumentation(documentation); + } + + return item; + } + +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleContextDetector.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleContextDetector.java new file mode 100644 index 0000000000..774726d3b0 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleContextDetector.java @@ -0,0 +1,326 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.NodeFinder; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.jdt.core.manipulation.SharedASTProviderCore; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.core.runtime.IProgressMonitor; + +/** + * Detects if we're in a ResourceBundle context (ResourceBundle.getString() calls). + */ +public class ResourceBundleContextDetector { + + private static final String RESOURCE_BUNDLE_CLASS = "java.util.ResourceBundle"; + private static final String GET_STRING_METHOD = "getString"; + + /** + * Result of detecting resource bundle context. + */ + public static class ResourceBundleContext { + public final String bundleName; + public final MethodInvocation invocation; + public final String locale; // Locale string (e.g., "fr", "fr_FR") extracted from getBundle() call, or null + + public ResourceBundleContext(String bundleName, MethodInvocation invocation, String locale) { + this.bundleName = bundleName; + this.invocation = invocation; + this.locale = locale; + } + } + + /** + * Bundle information extracted from method invocation. + */ + private static class BundleInfo { + final String bundleName; + final String locale; + + BundleInfo(String bundleName, String locale) { + this.bundleName = bundleName; + this.locale = locale; + } + } + + private final ResourceBundleNameExtractor nameExtractor; + private final ResourceBundleLocaleExtractor localeExtractor; + + public ResourceBundleContextDetector() { + this.nameExtractor = new ResourceBundleNameExtractor(); + this.localeExtractor = new ResourceBundleLocaleExtractor(); + } + + /** + * Detects if we're in a resource bundle context and returns both bundle name and method invocation. + * @return ResourceBundleContext with bundle name and invocation, or null if not in context + */ + public ResourceBundleContext detectContext(ICompilationUnit cu, int offset, IProgressMonitor monitor) { + try { + CompilationUnit ast = SharedASTProviderCore.getAST(cu, SharedASTProviderCore.WAIT_ACTIVE_ONLY, null); + if (ast == null) { + return null; + } + + // Try to find a node at the offset, expanding the search if needed + ASTNode node = NodeFinder.perform(ast, offset, 0); + if (node == null) { + // Try with a small range around the offset + node = NodeFinder.perform(ast, Math.max(0, offset - 1), 2); + } + + // If the node itself is a StringLiteral, check its parent + if (node instanceof StringLiteral) { + StringLiteral stringLiteral = (StringLiteral) node; + ASTNode parent = node.getParent(); + if (parent instanceof MethodInvocation invocation) { + BundleInfo bundleInfo = checkMethodInvocation(invocation, stringLiteral, offset); + return bundleInfo != null ? new ResourceBundleContext(bundleInfo.bundleName, invocation, bundleInfo.locale) : null; + } + } + + // Find the enclosing method invocation + MethodInvocation enclosingInvocation = findEnclosingMethodInvocation(node); + if (enclosingInvocation == null) { + return null; + } + + // Check if any of the arguments is a StringLiteral containing the offset + @SuppressWarnings("unchecked") + java.util.List arguments = enclosingInvocation.arguments(); + for (Expression arg : arguments) { + if (arg instanceof StringLiteral stringLiteral) { + if (isInsideStringLiteral(offset, stringLiteral)) { + BundleInfo bundleInfo = checkMethodInvocation(enclosingInvocation, stringLiteral, offset); + return bundleInfo != null ? new ResourceBundleContext(bundleInfo.bundleName, enclosingInvocation, bundleInfo.locale) : null; + } + } + } + + // Check if we're at a position where a string literal argument is expected but not yet created + // This handles cases like bundle.getString(|) where the quotes haven't been typed yet + // Only check if there are no arguments yet, or if we're at the first argument position + if (arguments.isEmpty() || isAtFirstArgumentPosition(enclosingInvocation, offset, arguments)) { + BundleInfo bundleInfo = checkMethodInvocation(enclosingInvocation, null, offset); + return bundleInfo != null ? new ResourceBundleContext(bundleInfo.bundleName, enclosingInvocation, bundleInfo.locale) : null; + } + } catch (Exception e) { + JavaLanguageServerPlugin.logException("Error detecting resource bundle context", e); + } + + return null; + } + + /** + * Checks if the offset is at the first argument position where a string argument would be expected. + * This handles the case when the cursor is at bundle.getString(|) before quotes are typed. + * For getString() which only takes one parameter, we should not provide completion after the first argument. + * Uses AST node positions and source code to verify we're actually in the argument list. + */ + private boolean isAtFirstArgumentPosition(MethodInvocation invocation, int offset, java.util.List arguments) { + try { + ASTNode nameNode = invocation.getName(); + if (nameNode == null) { + return false; + } + int nameEnd = nameNode.getStartPosition() + nameNode.getLength(); + + // Check if offset is after the method name + if (offset < nameEnd) { + return false; + } + + // Get source code to find parentheses and check for commas + ASTNode root = invocation.getRoot(); + if (!(root instanceof CompilationUnit rootCU)) { + return false; + } + ICompilationUnit cu = (ICompilationUnit) rootCU.getJavaElement(); + if (cu == null) { + return false; + } + String source = cu.getSource(); + if (source == null) { + return false; + } + + int invocationStart = invocation.getStartPosition(); + int invocationEnd = invocationStart + invocation.getLength(); + + // Find opening and closing parentheses + ResourceBundleTextProcessor.ParenthesisPositions parens = ResourceBundleTextProcessor.findParenthesisPositions(source, nameEnd, invocationEnd); + if (parens == null) { + return false; + } + int openParenPos = parens.openParenPos(); + int closeParenPos = parens.closeParenPos(); + + // Verify offset is within the parentheses + if (offset <= openParenPos || offset >= closeParenPos) { + return false; + } + + // If there are no arguments, we're at the first argument position + if (arguments.isEmpty()) { + return true; + } + + // If there's already an argument, check if we're still within it (not after a comma) + Expression firstArg = arguments.get(0); + int firstArgStart = firstArg.getStartPosition(); + int firstArgEnd = firstArgStart + firstArg.getLength(); + + // Check if offset is within the first argument + if (offset >= firstArgStart && offset <= firstArgEnd) { + return true; + } + + // Check if offset is after the first argument but before any comma + // This handles: bundle.getString("key"|) where cursor is right after the string + if (offset > firstArgEnd && offset < closeParenPos) { + // Check if there's a comma between the argument end and the offset + for (int i = firstArgEnd; i < offset && i < source.length(); i++) { + char c = source.charAt(i); + if (c == ',') { + // Found a comma, we're past the first argument position + return false; + } + if (!Character.isWhitespace(c)) { + // Found non-whitespace character (might be closing paren or other) + break; + } + } + // No comma found, we're still at the first argument position + return true; + } + + return false; + } catch (Exception e) { + // If we can't determine, fall back to false + return false; + } + } + + /** + * Checks if a method invocation is a resource bundle method and returns the bundle name and locale. + * @param invocation the method invocation + * @param stringLiteral the string literal argument (may be null if not yet created) + * @param offset the completion offset + * @return BundleInfo with bundle name and locale if this is a resource bundle method, null otherwise + */ + private BundleInfo checkMethodInvocation(MethodInvocation invocation, StringLiteral stringLiteral, int offset) { + IMethodBinding methodBinding = invocation.resolveMethodBinding(); + String methodName = null; + + if (methodBinding != null) { + methodName = methodBinding.getName(); + } + + // Fallback: if binding doesn't resolve, try to get method name from AST + if (methodName == null) { + ASTNode astNameNode = invocation.getName(); + if (astNameNode instanceof org.eclipse.jdt.core.dom.SimpleName nameNode) { + methodName = nameNode.getIdentifier(); + } + } + + // Check if it's ResourceBundle.getString() or a subclass of ResourceBundle + if (GET_STRING_METHOD.equals(methodName) && isResourceBundleSubclass(methodBinding)) { + // Check if we're inside a string literal, or if stringLiteral is null (not yet created) + if (stringLiteral == null || isInsideStringLiteral(offset, stringLiteral)) { + // Try to find the bundle name and locale from the receiver + Expression receiver = invocation.getExpression(); + String bundleName = nameExtractor.extractBundleName(receiver); + if (bundleName != null) { + String locale = localeExtractor.extractLocaleFromBundle(receiver, invocation.getRoot()); + return new BundleInfo(bundleName, locale); + } + } + } + + return null; + } + + /** + * Checks if the given type is ResourceBundle or a subclass of ResourceBundle. + * Uses efficient type binding checks. + */ + private boolean isResourceBundleSubclass(IMethodBinding methodBinding) { + if (methodBinding == null) { + return false; + } + ITypeBinding typeBinding = methodBinding.getDeclaringClass(); + if (typeBinding == null) { + return false; + } + + // Use erasure to handle generic types properly + typeBinding = typeBinding.getErasure(); + if (typeBinding == null) { + return false; + } + + // Check if it's ResourceBundle itself + if (RESOURCE_BUNDLE_CLASS.equals(typeBinding.getQualifiedName())) { + return true; + } + + // Walk up the superclass hierarchy to find ResourceBundle + ITypeBinding current = typeBinding.getSuperclass(); + while (current != null) { + current = current.getErasure(); + if (current == null) { + break; + } + if (RESOURCE_BUNDLE_CLASS.equals(current.getQualifiedName())) { + return true; + } + current = current.getSuperclass(); + } + + return false; + } + + /** + * Finds the enclosing method invocation node. + */ + private MethodInvocation findEnclosingMethodInvocation(ASTNode node) { + ASTNode current = node; + while (current != null) { + if (current instanceof MethodInvocation) { + return (MethodInvocation) current; + } + current = current.getParent(); + } + return null; + } + + /** + * Checks if the offset is inside the given string literal. + */ + private boolean isInsideStringLiteral(int offset, StringLiteral stringLiteral) { + int start = stringLiteral.getStartPosition(); + int end = start + stringLiteral.getLength(); + // The offset should be inside the string content (excluding quotes) + // We allow the offset to be at the end (after the last quote) for completion + return offset >= start + 1 && offset <= end; + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleLocaleExtractor.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleLocaleExtractor.java new file mode 100644 index 0000000000..836f1187f6 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleLocaleExtractor.java @@ -0,0 +1,288 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import java.util.List; +import java.util.Locale; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ClassInstanceCreation; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IBinding; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.Name; + +/** + * Extracts locale information from ResourceBundle.getBundle() calls. + * Handles Locale constants, new Locale() instances, and traces variable references. + */ +public class ResourceBundleLocaleExtractor { + + /** + * Extracts the locale from a ResourceBundle.getBundle() call. + * Handles cases like: + * - ResourceBundle.getBundle("messages", Locale.FRENCH) + * - ResourceBundle.getBundle("messages", new Locale("fr")) + * - ResourceBundle.getBundle("messages", Locale.getDefault()) + * + * @param bundleExpression the expression that represents the bundle (e.g., bundle variable) + * @param root the AST root node + * @return locale string (e.g., "fr", "fr_FR") or null if not found or not applicable + */ + public String extractLocaleFromBundle(Expression bundleExpression, ASTNode root) { + if (bundleExpression == null || root == null) { + return null; + } + + // Find the ResourceBundle.getBundle() call that created this bundle + MethodInvocation getBundleInvocation = findGetBundleInvocation(bundleExpression, root); + if (getBundleInvocation == null) { + return null; + } + + @SuppressWarnings("unchecked") + List arguments = getBundleInvocation.arguments(); + + // getBundle() can have 1-4 arguments, locale is typically the second argument + // getBundle(String baseName) + // getBundle(String baseName, Locale locale) + // getBundle(String baseName, Locale locale, ClassLoader loader) + // getBundle(String baseName, Locale locale, ClassLoader loader, Control control) + if (arguments.size() < 2) { + return null; // No locale parameter + } + + Expression localeArg = arguments.get(1); + return extractLocaleFromExpression(localeArg, root); + } + + /** + * Finds the ResourceBundle.getBundle() invocation that created the given bundle expression. + */ + private MethodInvocation findGetBundleInvocation(Expression bundleExpression, ASTNode root) { + return ASTExpressionHelper.findGetBundleInvocation(bundleExpression, root); + } + + /** + * Extracts locale string from an expression representing a Locale. + * Handles: + * - Locale.FRENCH -> "fr" + * - Locale.FRANCE -> "fr_FR" + * - new Locale("fr") -> "fr" + * - new Locale("fr", "FR") -> "fr_FR" + * - Locale.getDefault() -> null (can't determine at compile time) + */ + private String extractLocaleFromExpression(Expression localeExpression, ASTNode root) { + if (localeExpression == null) { + return null; + } + + String fieldName = null; + + // Field access: Locale.FRENCH, Locale.FRANCE, etc. + if (localeExpression instanceof org.eclipse.jdt.core.dom.FieldAccess fieldAccess) { + IBinding binding = fieldAccess.getName().resolveBinding(); + + // Try to get field name from binding first + if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding vb) { + fieldName = vb.getName(); + } else { + // Fallback: extract field name directly from AST if binding resolution fails + org.eclipse.jdt.core.dom.SimpleName nameNode = fieldAccess.getName(); + if (nameNode != null) { + fieldName = nameNode.getIdentifier(); + } + } + } + // Qualified name: Locale.FRENCH, Locale.FRANCE, etc. (alternative representation) + else if (localeExpression instanceof org.eclipse.jdt.core.dom.QualifiedName qualifiedName) { + IBinding binding = qualifiedName.resolveBinding(); + + // Try to get field name from binding first + if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding vb) { + fieldName = vb.getName(); + } else { + // Fallback: extract field name directly from AST if binding resolution fails + org.eclipse.jdt.core.dom.SimpleName nameNode = qualifiedName.getName(); + if (nameNode != null) { + fieldName = nameNode.getIdentifier(); + } + } + } + + if (fieldName != null) { + // Map common Locale constants to locale strings + switch (fieldName) { + case "FRENCH": return "fr"; + case "FRANCE": return "fr_FR"; + case "ENGLISH": return "en"; + case "US": return "en_US"; + case "UK": return "en_GB"; + case "GERMAN": return "de"; + case "GERMANY": return "de_DE"; + case "ITALIAN": return "it"; + case "ITALY": return "it_IT"; + case "SPANISH": return "es"; + case "JAPANESE": return "ja"; + case "JAPAN": return "ja_JP"; + case "KOREAN": return "ko"; + case "KOREA": return "ko_KR"; + case "CHINESE": return "zh"; + case "SIMPLIFIED_CHINESE": return "zh_CN"; + case "TRADITIONAL_CHINESE": return "zh_TW"; + default: + // Try to extract from field name if it follows a pattern + // This is a best-effort approach + return null; + } + } + + // Class instance creation: new Locale("fr") or new Locale("fr", "FR") + if (localeExpression instanceof ClassInstanceCreation creation) { + ITypeBinding typeBinding = creation.resolveTypeBinding(); + if (typeBinding != null && "java.util.Locale".equals(typeBinding.getQualifiedName())) { + @SuppressWarnings("unchecked") + List arguments = creation.arguments(); + if (!arguments.isEmpty()) { + // First argument is language code + Expression langArg = arguments.get(0); + String language = extractStringFromExpression(langArg, root); + if (language == null) { + return null; + } + + // Second argument (if present) is country code + if (arguments.size() >= 2) { + Expression countryArg = arguments.get(1); + String country = extractStringFromExpression(countryArg, root); + if (country != null && !country.isEmpty()) { + return language + "_" + country; + } + } + + return language; + } + } + } + + // Method invocation: Locale.of("fr"), Locale.of("fr", "FR"), Locale.forLanguageTag("fr-FR"), or Locale.getDefault() + if (localeExpression instanceof MethodInvocation invocation) { + IMethodBinding binding = invocation.resolveMethodBinding(); + String methodName = null; + ITypeBinding declaringClass = null; + + if (binding != null) { + methodName = binding.getName(); + declaringClass = binding.getDeclaringClass(); + } else { + // Fallback: extract method name from AST if binding resolution fails + ASTNode nameNode = invocation.getName(); + if (nameNode instanceof org.eclipse.jdt.core.dom.SimpleName simpleName) { + methodName = simpleName.getIdentifier(); + } + // For static methods, check the receiver (e.g., "Locale" in "Locale.of()") + // The receiver can be an Expression or a Name (for static method calls) + Expression receiverExpr = invocation.getExpression(); + if (receiverExpr != null) { + ITypeBinding receiverType = receiverExpr.resolveTypeBinding(); + if (receiverType != null && "java.util.Locale".equals(receiverType.getQualifiedName())) { + declaringClass = receiverType; + } + } else { + // For static method calls, the receiver might be a Name (e.g., "Locale") + // Check if the method invocation's name is qualified + Name methodNameNode = invocation.getName(); + if (methodNameNode instanceof org.eclipse.jdt.core.dom.QualifiedName qualifiedName) { + Name qualifier = qualifiedName.getQualifier(); + ITypeBinding qualifierType = qualifier.resolveTypeBinding(); + if (qualifierType != null && "java.util.Locale".equals(qualifierType.getQualifiedName())) { + declaringClass = qualifierType; + } + } + } + } + + // Only handle methods from java.util.Locale + if (declaringClass == null || !"java.util.Locale".equals(declaringClass.getQualifiedName())) { + return null; + } + + if (methodName == null) { + return null; + } + + // Handle Locale.of() factory methods + if ("of".equals(methodName)) { + @SuppressWarnings("unchecked") + List arguments = invocation.arguments(); + if (!arguments.isEmpty()) { + // First argument is language code + Expression langArg = arguments.get(0); + String language = extractStringFromExpression(langArg, root); + if (language == null || language.isEmpty()) { + return null; + } + + // Second argument (if present) is country code + // Locale.of() can have 1-3 arguments: (language), (language, country), or (language, country, variant) + if (arguments.size() >= 2) { + Expression countryArg = arguments.get(1); + String country = extractStringFromExpression(countryArg, root); + if (country != null && !country.isEmpty()) { + // Return locale string with language and country + return language + "_" + country; + } + // If country extraction failed, fall back to language only + return language; + } + + // Only language argument provided + return language; + } + return null; + } + // Handle Locale.forLanguageTag() - accepts BCP 47 language tags (e.g., "fr", "fr-FR") + else if ("forLanguageTag".equals(methodName)) { + @SuppressWarnings("unchecked") + List arguments = invocation.arguments(); + if (!arguments.isEmpty()) { + Expression tagArg = arguments.get(0); + String languageTag = extractStringFromExpression(tagArg, root); + if (languageTag != null && !languageTag.isEmpty()) { + // Convert BCP 47 format (hyphens) to our internal format (underscores) + // e.g., "fr-FR" -> "fr_FR", "fr" -> "fr" + return languageTag.replace('-', '_'); + } + } + return null; + } + // Handle Locale.getDefault() - can't determine at compile time + else if ("getDefault".equals(methodName)) { + // Can't determine default locale at compile time, so assume it's the same as the current locale + return Locale.getDefault().getLanguage() + "_" + Locale.getDefault().getCountry(); + } + } + + return null; + } + + /** + * Extracts a string value from an expression. + * Handles string literals and traces back variables. + */ + private String extractStringFromExpression(Expression expression, ASTNode root) { + return ASTExpressionHelper.extractStringFromExpression(expression, root); + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleNameExtractor.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleNameExtractor.java new file mode 100644 index 0000000000..26260e0913 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleNameExtractor.java @@ -0,0 +1,117 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import java.util.List; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.StringLiteral; + +/** + * Extracts bundle names from AST expressions. + * Handles string literals, method invocations, field access, and variable references. + */ +public class ResourceBundleNameExtractor { + + /** + * Extracts the bundle name from the expression (e.g., ResourceBundle.getBundle("bundleName")). + * Also traces back variable assignments to find the bundle name. + */ + public String extractBundleName(Expression expression) { + if (expression == null) { + return null; + } + ASTNode root = expression.getRoot(); + return extractBundleNameFromExpression(expression, root); + } + + /** + * Extracts bundle name from an expression, handling direct string literals, + * ResourceBundle.getBundle() calls, field access, and variable references. + */ + private String extractBundleNameFromExpression(Expression expression, ASTNode root) { + if (expression == null) { + return null; + } + + // Direct string literal: var name = "messages"; + if (expression instanceof StringLiteral stringLiteral) { + return stringLiteral.getLiteralValue(); + } + + // Method invocation: var bundle = ResourceBundle.getBundle("messages"); + if (expression instanceof MethodInvocation invocation) { + IMethodBinding binding = invocation.resolveMethodBinding(); + if (binding != null && "getBundle".equals(binding.getName())) { + @SuppressWarnings("unchecked") + List arguments = invocation.arguments(); + if (!arguments.isEmpty()) { + Expression arg = arguments.get(0); + // If argument is a string literal, return it directly + if (arg instanceof StringLiteral stringLiteral) { + return stringLiteral.getLiteralValue(); + } + // If argument is a variable or field access, trace it back recursively + org.eclipse.jdt.core.dom.IVariableBinding vb = ASTExpressionHelper.extractVariableBinding(arg); + if (vb != null) { + return findBundleNameFromVariableBinding(vb, root); + } + } + } + } + + // Field access or variable reference: extract the variable binding and trace it + org.eclipse.jdt.core.dom.IVariableBinding vb = ASTExpressionHelper.extractVariableBinding(expression); + if (vb != null) { + return findBundleNameFromVariableBinding(vb, root); + } + + return null; + } + + /** + * Finds the bundle name by tracing back to where a variable was assigned. + */ + private String findBundleNameFromVariableBinding(org.eclipse.jdt.core.dom.IVariableBinding varBinding, ASTNode root) { + if (varBinding == null || root == null) { + return null; + } + + // Find the variable declaration or assignment in the AST + ASTExpressionHelper.VariableFinder finder = new ASTExpressionHelper.VariableFinder(varBinding); + root.accept(finder); + + // First check if there's an initializer in the declaration + if (finder.declarationFragment != null) { + Expression initializer = finder.declarationFragment.getInitializer(); + String bundleName = extractBundleNameFromExpression(initializer, root); + if (bundleName != null) { + return bundleName; + } + } + + // If no initializer, check for assignment statements + if (finder.assignment != null) { + Expression rightHandSide = finder.assignment.getRightHandSide(); + String bundleName = extractBundleNameFromExpression(rightHandSide, root); + if (bundleName != null) { + return bundleName; + } + } + + return null; + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundlePropertiesFinder.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundlePropertiesFinder.java new file mode 100644 index 0000000000..2f11bc1359 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundlePropertiesFinder.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; + +/** + * Finds and processes resource bundles using the ResourceBundle API. + * Uses a project-specific ClassLoader to load bundles, which automatically handles: + * - Classpath order + * - Locale fallback + * - ResourceBundle search algorithm + * - All ResourceBundle formats (properties files, ListResourceBundle, etc.) + */ +public class ResourceBundlePropertiesFinder { + + /** + * Finds all resource bundle keys from the project using ResourceBundle.getBundle(). + * This approach leverages the standard ResourceBundle API which handles: + * - Classpath order automatically + * - Locale fallback (e.g., fr_FR -> fr -> default) + * - All ResourceBundle formats (properties files, ListResourceBundle subclasses) + * - Proper encoding handling + * + * @param javaProject the Java project + * @param bundleName the bundle name (e.g., "messages" or "com.example.messages") + * @param locale the locale string (e.g., "fr", "fr_FR") or null to use default locale + * @param monitor the progress monitor + * @return map of keys to values, with locale-specific values prioritized if locale is provided + */ + public Map findResourceBundleKeys(IJavaProject javaProject, String bundleName, String locale, IProgressMonitor monitor) { + Map keyValueMap = new LinkedHashMap<>(); + if (bundleName == null || bundleName.isEmpty() || javaProject == null) { + return keyValueMap; + } + + ClassLoader classLoader = null; + try { + // Create a classloader for the project + classLoader = ProjectClassLoader.createClassLoader(javaProject, monitor); + if (classLoader == null) { + return keyValueMap; + } + + // Parse locale string to Locale object + Locale targetLocale = parseLocale(locale); + + // Use ResourceBundle.getBundle() which handles all the complexity: + // - Searches classpath in order + // - Handles locale fallback automatically + // - Supports all ResourceBundle formats + ResourceBundle bundle; + try { + bundle = ResourceBundle.getBundle(bundleName, targetLocale, classLoader); + } catch (MissingResourceException e) { + // Bundle doesn't exist, return empty map + return keyValueMap; + } + + // Verify the bundle was loaded correctly by checking if we can access it + // This helps debug cases where ResourceBundle falls back to a less specific locale + if (bundle == null) { + return keyValueMap; + } + + // Extract all keys and values from the bundle + // ResourceBundle.getKeys() returns keys from this bundle and all parent bundles + Enumeration keys = bundle.getKeys(); + while (keys.hasMoreElements()) { + if (monitor.isCanceled()) { + break; + } + String key = keys.nextElement(); + try { + // getString() automatically uses the most specific bundle in the fallback chain + String value = bundle.getString(key); + if (value != null) { + keyValueMap.put(key, value); + } + } catch (MissingResourceException e) { + // Skip keys that don't have string values (might be other types) + continue; + } + } + + } catch (Exception e) { + JavaLanguageServerPlugin.logException("Error finding resource bundle keys", e); + } finally { + // Prevent ClassLoader leak: ResourceBundle.getBundle() caches bundles internally, + // and cached bundles hold references to the ClassLoader. Clear the cache for this + // ClassLoader to allow it to be garbage collected. + if (classLoader != null) { + ResourceBundle.clearCache(classLoader); + } + } + + return keyValueMap; + } + + /** + * Parses a locale string (e.g., "fr", "fr_FR") into a Locale object. + * Returns Locale.getDefault() if locale is null or empty. + * + * @param localeString the locale string (e.g., "fr", "fr_FR") + * @return the Locale object, or Locale.getDefault() if localeString is null/empty + */ + private Locale parseLocale(String localeString) { + if (localeString == null || localeString.isEmpty()) { + return Locale.getDefault(); + } + + // Handle format: "fr" or "fr_FR" + String[] parts = localeString.split("_"); + if (parts.length == 1) { + return Locale.of(parts[0]); + } else if (parts.length >= 2) { + return Locale.of(parts[0], parts[1]); + } + + return Locale.getDefault(); + } + +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleTextProcessor.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleTextProcessor.java new file mode 100644 index 0000000000..3c82d5fd36 --- /dev/null +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/contentassist/resourcebundle/ResourceBundleTextProcessor.java @@ -0,0 +1,293 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle; + +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.lsp4j.Range; + +/** + * Processes document text for resource bundle completion. + * Handles prefix extraction, quote position finding, and range calculation. + */ +public class ResourceBundleTextProcessor { + + /** + * Result of finding quote positions in a string literal. + */ + public static record QuotePositions(int openingQuote, int closingQuote) { + public boolean isValid() { + return openingQuote >= 0 && closingQuote > openingQuote; + } + } + + /** + * Result of finding parenthesis positions in a method invocation. + */ + public static record ParenthesisPositions(int openParenPos, int closeParenPos) { + } + + /** + * Gets the prefix string at the current offset. + * This extracts the partial key that the user has typed so far. + * Handles both cases: inside quotes (bundle.getString("key|")) and outside quotes (bundle.getString(key|)). + */ + public String getPrefix(IDocument document, int offset, QuotePositions quotes) { + try { + if (offset < 0 || offset > document.getLength()) { + return ""; + } + boolean insideQuotes = quotes.openingQuote >= 0; + + int start = offset; + // Find the start of the current word (backwards from offset) + // Stop at the opening quote, opening parenthesis, comma, or whitespace + while (start > 0) { + char c = document.getChar(start - 1); + if (c == '"' || c == '(' || c == ',' || Character.isWhitespace(c)) { + break; + } + if (!isKeyChar(c)) { + break; + } + start--; + } + + // Find the end of the current word (forwards from offset) + // When inside quotes, only look up to the cursor position (don't include text after cursor) + // When outside quotes, stop at closing parenthesis, comma, or non-key character + int end = offset; + if (insideQuotes) { + // When inside quotes, only extract prefix up to the cursor position + // Don't include text that comes after the cursor + end = offset; + } else { + // When outside quotes, stop at closing parenthesis, comma, or non-key character + while (end < document.getLength()) { + char c = document.getChar(end); + if (c == ')' || c == ',' || Character.isWhitespace(c)) { + break; + } + if (!isKeyChar(c)) { + break; + } + end++; + } + } + + if (start < end) { + return document.get(start, end - start); + } + return ""; + } catch (BadLocationException e) { + return ""; + } + } + + /** + * Finds the positions of opening and closing parentheses in a method invocation. + * + * @param source the source code string + * @param nameEnd the end position of the method name + * @param invocationEnd the end position of the method invocation + * @return ParenthesisPositions with the parenthesis positions, or null if not found + */ + public static ParenthesisPositions findParenthesisPositions(String source, int nameEnd, int invocationEnd) { + // Find opening parenthesis after method name + int openParenPos = -1; + for (int i = nameEnd; i < invocationEnd && i < source.length(); i++) { + if (source.charAt(i) == '(') { + openParenPos = i; + break; + } + } + if (openParenPos < 0) { + return null; + } + + // Find closing parenthesis + int closeParenPos = -1; + int depth = 1; + for (int i = openParenPos + 1; i < invocationEnd && i < source.length(); i++) { + char c = source.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + closeParenPos = i; + break; + } + } + } + if (closeParenPos < 0) { + return null; + } + + return new ParenthesisPositions(openParenPos, closeParenPos); + } + + /** + * Finds the positions of opening and closing quotes around the given offset. + * Verifies that quotes are part of the method invocation's argument list if invocation is provided. + * @param document the document + * @param offset the offset + * @param invocation optional method invocation to verify quotes are within its argument list + * @return QuotePositions with the quote positions, or invalid positions if not found or not valid + */ + public QuotePositions findQuotePositions(IDocument document, int offset, MethodInvocation invocation) { + try { + if (offset < 0 || offset > document.getLength()) { + return new QuotePositions(-1, -1); + } + + // Find the opening quote (backwards from offset) + int openingQuote = -1; + for (int i = offset - 1; i >= 0; i--) { + char c = document.getChar(i); + if (c == '"') { + openingQuote = i; + break; + } + if (c == '(' || c == ',' || Character.isWhitespace(c)) { + break; + } + } + + // Find the closing quote (forwards from offset) + int closingQuote = -1; + for (int i = offset; i < document.getLength(); i++) { + char c = document.getChar(i); + if (c == '"') { + closingQuote = i; + break; + } + } + + QuotePositions quotes = new QuotePositions(openingQuote, closingQuote); + + // Verify quotes are within the method invocation's argument list if invocation is provided + if (quotes.isValid() && invocation != null) { + if (!areQuotesInArgumentList(document, quotes, invocation)) { + return new QuotePositions(-1, -1); + } + } + + return quotes; + } catch (BadLocationException e) { + return new QuotePositions(-1, -1); + } + } + + /** + * Verifies that the quote positions are within the method invocation's argument list. + * This prevents matching quotes from comments or other string literals outside the invocation. + */ + private boolean areQuotesInArgumentList(IDocument document, QuotePositions quotes, MethodInvocation invocation) { + try { + ASTNode root = invocation.getRoot(); + if (!(root instanceof CompilationUnit rootCU)) { + return false; + } + ICompilationUnit cu = (ICompilationUnit) rootCU.getJavaElement(); + if (cu == null) { + return false; + } + String source = cu.getSource(); + if (source == null) { + return false; + } + + ASTNode nameNode = invocation.getName(); + if (nameNode == null) { + return false; + } + int nameEnd = nameNode.getStartPosition() + nameNode.getLength(); + int invocationEnd = invocation.getStartPosition() + invocation.getLength(); + + // Find opening and closing parentheses + ParenthesisPositions parens = findParenthesisPositions(source, nameEnd, invocationEnd); + if (parens == null) { + return false; + } + int openParenPos = parens.openParenPos(); + int closeParenPos = parens.closeParenPos(); + + // Verify quotes are within the parentheses (argument list) + return quotes.openingQuote > openParenPos && quotes.closingQuote < closeParenPos; + } catch (Exception e) { + // If we can't verify, assume quotes are valid (fallback to original behavior) + return true; + } + } + + /** + * Calculates the range to replace based on the prefix. + * When inside quotes, replaces the entire string content (from opening quote to closing quote). + * @param quotes the quote positions (can be invalid if not inside quotes) + */ + public Range calculateRange(IDocument document, int offset, String prefix, QuotePositions quotes) { + try { + if (quotes.isValid()) { + // When inside quotes, replace the entire string content + // Replace everything between the quotes (excluding the quotes themselves) + int start = quotes.openingQuote + 1; + int length = quotes.closingQuote - start; + return JDTUtils.toRange(document, start, length); + } + + // Fallback: replace just the prefix + if (prefix.isEmpty()) { + // If no prefix, just insert at the current position + return JDTUtils.toRange(document, offset, 0); + } + // Calculate the start position of the prefix + int start = offset - prefix.length(); + int length = prefix.length(); + return JDTUtils.toRange(document, start, length); + } catch (Exception e) { + // Fallback: create a simple range at the offset + return createFallbackRange(document, offset); + } + } + + /** + * Creates a fallback range at the given offset when normal range calculation fails. + */ + private Range createFallbackRange(IDocument document, int offset) { + try { + return JDTUtils.toRange(document, offset, 0); + } catch (Exception e) { + try { + int[] loc = org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers.toLine(document, offset); + org.eclipse.lsp4j.Position pos = new org.eclipse.lsp4j.Position(loc[0], loc[1]); + return new Range(pos, pos); + } catch (Exception e2) { + org.eclipse.lsp4j.Position pos = new org.eclipse.lsp4j.Position(0, 0); + return new Range(pos, pos); + } + } + } + + /** + * Checks if a character is valid in a resource bundle key. + */ + private boolean isKeyChar(char c) { + return Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == '-'; + } +} diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CompletionHandler.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CompletionHandler.java index 4a6172ebdb..8690c05286 100644 --- a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CompletionHandler.java +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/handlers/CompletionHandler.java @@ -49,6 +49,7 @@ import org.eclipse.jdt.ls.core.internal.contentassist.JavadocCompletionProposal; import org.eclipse.jdt.ls.core.internal.contentassist.SnippetCompletionProposal; import org.eclipse.jdt.ls.core.internal.contentassist.SortTextHelper; +import org.eclipse.jdt.ls.core.internal.contentassist.resourcebundle.ResourceBundleCompletionProposal; import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager; import org.eclipse.jdt.ls.core.internal.syntaxserver.ModelBasedCompletionEngine; import org.eclipse.lsp4j.Command; @@ -299,6 +300,9 @@ public boolean isCanceled() { proposals.addAll(SnippetCompletionProposal.getSnippets(unit, collector, subMonitor)); } proposals.addAll(new JavadocCompletionProposal().getProposals(unit, offset, collector, subMonitor)); + if (manager.getPreferences().isResourceBundleCompletionEnabled()) { + proposals.addAll(new ResourceBundleCompletionProposal().getProposals(unit, offset, collector, subMonitor)); + } } catch (OperationCanceledException e) { monitor.setCanceled(true); } @@ -308,6 +312,7 @@ public boolean isCanceled() { List tempProposals = proposals.stream().filter(prop -> prop.getKind() == CompletionItemKind.Keyword || prop.getKind() == CompletionItemKind.Snippet).collect(Collectors.toList()); tempProposals.sort(LABEL_COMPARATOR); int newSortText = SortTextHelper.CEILING; + boolean foundMatch = false; for (int i = 0; i < tempProposals.size() - 1; i++) { CompletionItem currentItem = tempProposals.get(i); CompletionItem nextItem = tempProposals.get(i + 1); @@ -320,10 +325,11 @@ public boolean isCanceled() { } if (tempSortText < newSortText) { newSortText = tempSortText; + foundMatch = true; } } } - if (newSortText != -1) { + if (foundMatch) { String finalSortText = Integer.toString(newSortText); tempProposals.stream().filter(prop -> prop.getKind() == CompletionItemKind.Snippet).forEach(p -> p.setSortText(finalSortText)); } diff --git a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java index da10f6704e..475b8a7320 100644 --- a/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java +++ b/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java @@ -546,6 +546,11 @@ public class Preferences { */ public static final String CHAIN_COMPLETION_KEY = "java.completion.chain.enabled"; + /** + * Preference key to enable/disable resource bundle completion. + */ + public static final String RESOURCE_BUNDLE_COMPLETION_KEY = "java.completion.resourceBundle.enabled"; + /** * Preference key to set the scope value to use when searching java code. Allowed value are *
    @@ -724,6 +729,7 @@ public class Preferences { private boolean telemetryEnabled; private boolean validateAllOpenBuffersOnChanges; private boolean chainCompletionEnabled; + private boolean resourceBundleCompletionEnabled; private List diagnosticFilter; private SearchScope searchScope; private boolean inlayHintsSuppressedWhenSameNameNumberedParameter; @@ -985,6 +991,7 @@ public Preferences() { extractInterfaceReplaceEnabled = false; telemetryEnabled = false; validateAllOpenBuffersOnChanges = true; + resourceBundleCompletionEnabled = true; diagnosticFilter = new ArrayList<>(); searchScope = SearchScope.all; } @@ -1149,6 +1156,7 @@ public Preferences clone() { prefs.telemetryEnabled = this.telemetryEnabled; prefs.validateAllOpenBuffersOnChanges = this.validateAllOpenBuffersOnChanges; prefs.chainCompletionEnabled = this.chainCompletionEnabled; + prefs.resourceBundleCompletionEnabled = this.resourceBundleCompletionEnabled; prefs.searchScope = this.searchScope; // Deep copy collections @@ -1826,6 +1834,11 @@ public static Preferences updateFrom(Preferences existing, Map c prefs.setChainCompletionEnabled(chainCompletionEnabled); } + if (containsKey(configuration, RESOURCE_BUNDLE_COMPLETION_KEY)) { + boolean resourceBundleCompletionEnabled = getBoolean(configuration, RESOURCE_BUNDLE_COMPLETION_KEY, existing.resourceBundleCompletionEnabled); + prefs.setResourceBundleCompletionEnabled(resourceBundleCompletionEnabled); + } + if (containsKey(configuration, JAVA_DIAGNOSTIC_FILER)) { List diagnosticFilter = getList(configuration, JAVA_DIAGNOSTIC_FILER, existing.diagnosticFilter); prefs.setDiagnosticFilter(diagnosticFilter); @@ -2944,6 +2957,14 @@ public boolean isChainCompletionEnabled() { return this.chainCompletionEnabled; } + public void setResourceBundleCompletionEnabled(boolean resourceBundleCompletionEnabled) { + this.resourceBundleCompletionEnabled = resourceBundleCompletionEnabled; + } + + public boolean isResourceBundleCompletionEnabled() { + return this.resourceBundleCompletionEnabled; + } + /** * update the null analysis options of all projects based on the null analysis mode * Returns the list of enabled clean ups. diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.classpath b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.classpath new file mode 100644 index 0000000000..fc85a416d7 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.classpath @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.project b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.project new file mode 100644 index 0000000000..b9b57b5a77 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/.project @@ -0,0 +1,19 @@ + + + resourcebundle + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + + + diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/org/sample/ResourceBundleTest.java b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/org/sample/ResourceBundleTest.java new file mode 100644 index 0000000000..c75ff43663 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/org/sample/ResourceBundleTest.java @@ -0,0 +1,14 @@ +package org.sample; + +import java.util.ResourceBundle; + +public class ResourceBundleTest { + private ResourceBundle bundle; + + public void testResourceBundle() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(""); + } +} + + diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages.properties b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages.properties new file mode 100644 index 0000000000..cb1a076c64 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages.properties @@ -0,0 +1,15 @@ +# Resource bundle messages +greeting.hello=Hello +greeting.goodbye=Goodbye +error.notfound=Not found +error.unauthorized=Unauthorized access +info.loading=Loading... +info.success=Success +user.name=Name +user.email=Email +user.phone=Phone +app.title=Application Title +app.version=Version +message.multiline=This is a multiline message.\nIt has multiple lines.\nEach line should be displayed separately. + + diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_en.properties b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_en.properties new file mode 100644 index 0000000000..d14f336928 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_en.properties @@ -0,0 +1,7 @@ +# English resource bundle messages +greeting.hello=Hello +greeting.goodbye=Goodbye +error.notfound=Not found +error.unauthorized=Unauthorized access + + diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr.properties b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr.properties new file mode 100644 index 0000000000..ff21753859 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr.properties @@ -0,0 +1,12 @@ +# French resource bundle messages +greeting.hello=Bonjour +greeting.goodbye=Au revoir +error.notfound=Non trouvé +error.unauthorized=Accès non autorisé +info.loading=Chargement... +info.success=Succès +user.name=Nom +user.email=Courriel +user.phone=Téléphone +app.title=Titre de l'application +app.version=Version diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr_FR.properties b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr_FR.properties new file mode 100644 index 0000000000..081c6bcc38 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/messages_fr_FR.properties @@ -0,0 +1,14 @@ +# French (France) resource bundle messages +greeting.hello=Bonjour +greeting.goodbye=Au revoir +error.notfound=Non trouvé +error.unauthorized=Accès non autorisé +info.loading=Chargement... +info.success=Succès +user.name=Nom +user.email=Courriel +user.phone=Téléphone +app.title=Titre de l'application +app.version=Version +# France-specific key +greeting.formal=Bonjour, comment allez-vous? diff --git a/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/validation.properties b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/validation.properties new file mode 100644 index 0000000000..3fd0ab8170 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/projects/eclipse/resourcebundle/src/resources/validation.properties @@ -0,0 +1,8 @@ +# Validation messages +validation.required=This field is required +validation.email=Invalid email address +validation.phone=Invalid phone number +validation.minlength=Minimum length is {0} +validation.maxlength=Maximum length is {0} + + diff --git a/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/ResourceBundleCompletionTest.java b/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/ResourceBundleCompletionTest.java new file mode 100644 index 0000000000..b0812f5cc0 --- /dev/null +++ b/org.eclipse.jdt.ls.tests/src/org/eclipse/jdt/ls/core/internal/handlers/ResourceBundleCompletionTest.java @@ -0,0 +1,943 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat Inc. and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ls.core.internal.handlers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.manipulation.CoreASTProvider; +import org.eclipse.jdt.ls.core.internal.JsonMessageHelper; +import org.eclipse.jdt.ls.core.internal.preferences.ClientPreferences; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.Range; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Integration tests for resource bundle key completion. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ResourceBundleCompletionTest extends AbstractCompilationUnitBasedTest { + + private static String COMPLETION_TEMPLATE = """ + { + "id": "1", + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "${file}" + }, + "position": { + "line": ${line}, + "character": ${char} + }, + "context": { + "triggerKind": 1 + } + }, + "jsonrpc": "2.0" + }"""; + + @BeforeEach + @Override + public void setup() throws Exception { + mockLSP3Client(); + reset(); + setupEclipseProject("resourcebundle"); + } + + @AfterEach + public void reset() throws Exception { + preferences.setResourceBundleCompletionEnabled(true); + CoreASTProvider.getInstance().disposeAST(); + } + + private CompletionList requestCompletions(ICompilationUnit unit, String completeBehind) throws JavaModelException { + return requestCompletions(unit, completeBehind, 0); + } + + private CompletionList requestCompletions(ICompilationUnit unit, String completeBehind, int fromIndex) throws JavaModelException { + int[] loc = findCompletionLocation(unit, completeBehind, fromIndex); + CoreASTProvider.getInstance().setActiveJavaElement(unit); + return server.completion(JsonMessageHelper.getParams(createCompletionRequest(unit, loc[0], loc[1]))).join().getRight(); + } + + private String createCompletionRequest(ICompilationUnit unit, int line, int kar) { + return COMPLETION_TEMPLATE.replace("${file}", org.eclipse.jdt.ls.core.internal.JDTUtils.toURI(unit)) + .replace("${line}", String.valueOf(line)) + .replace("${char}", String.valueOf(kar)); + } + + private void mockLSP3Client() { + mockLSPClient(true, true); + } + + private void mockLSPClient(boolean isSnippetSupported, boolean isSignatureHelpSupported) { + ClientPreferences clientPreferences = mock(ClientPreferences.class); + when(clientPreferences.isCompletionSnippetsSupported()).thenReturn(isSnippetSupported); + when(clientPreferences.isCompletionListItemDefaultsPropertySupport(anyString())).thenReturn(false); + when(preferenceManager.getClientPreferences()).thenReturn(clientPreferences); + } + + @Test + public void testResourceBundleGetStringCompletionWithPrefix() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have filtered resource bundle key completions"); + + // All items should start with "greeting." + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // Should not contain keys that don't start with "greeting." + assertFalse(labels.contains("error.notfound"), "Should not contain error.notfound"); + } + + @Test + public void testResourceBundleCompletionInMiddleOfExistingString() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString("greeting.goodbye"); + } + } + """); + + // Request completion after "greeting." in the middle of the existing string + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions when cursor is in middle of string"); + + // Should show both greeting.hello and greeting.goodbye + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + + // Verify that the text edit range replaces the entire string content + CompletionItem helloItem = resourceBundleItems.stream() + .filter(item -> "greeting.hello".equals(item.getLabel())) + .findFirst() + .orElse(null); + assertNotNull(helloItem, "Should find greeting.hello completion item"); + assertNotNull(helloItem.getTextEdit(), "Should have text edit"); + Range range = helloItem.getTextEdit().getLeft().getRange(); + // The range should replace the entire content between quotes + // Verify that the range covers the entire "greeting.goodbye" string (not just "greeting.") + // The range should start after the opening quote and end before the closing quote + assertTrue(range.getStart().getCharacter() > 0 && range.getEnd().getCharacter() > range.getStart().getCharacter(), + "Text edit should replace entire string content"); + // Verify the insert text is just the key (without quotes, since we're inside quotes) + String insertText = helloItem.getTextEdit().getLeft().getNewText(); + assertEquals("greeting.hello", insertText, "Insert text should be the key without quotes"); + } + + @Test + public void testResourceBundleCompletionWithDifferentBundle() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.validation"); + String value = bundle.getString(""); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\""); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + // Should contain validation keys + assertTrue(labels.contains("validation.required"), "Should contain validation.required"); + assertTrue(labels.contains("validation.email"), "Should contain validation.email"); + + // Should NOT contain keys from messages bundle + assertFalse(labels.contains("greeting.hello"), "Should not contain greeting.hello from messages bundle"); + assertFalse(labels.contains("error.notfound"), "Should not contain error.notfound from messages bundle"); + assertFalse(labels.contains("user.name"), "Should not contain user.name from messages bundle"); + } + + @Test + public void testResourceBundleCompletionExcludesOtherBundles() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(""); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\""); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + // Should contain messages keys + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("error.notfound"), "Should contain error.notfound"); + assertTrue(labels.contains("user.name"), "Should contain user.name"); + + // Should NOT contain keys from validation bundle + assertFalse(labels.contains("validation.required"), "Should not contain validation.required from validation bundle"); + assertFalse(labels.contains("validation.email"), "Should not contain validation.email from validation bundle"); + assertFalse(labels.contains("validation.phone"), "Should not contain validation.phone from validation bundle"); + } + + @Test + public void testResourceBundleCompletionDocumentation() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(""); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\""); + assertNotEquals(0, list.getItems().size(), "Completion list should not be empty"); + + // Find a resource bundle completion item + CompletionItem item = list.getItems().stream() + .filter(i -> i.getKind() == CompletionItemKind.Property && "greeting.hello".equals(i.getLabel())) + .findFirst() + .orElse(null); + + assertNotNull(item, "Should find greeting.hello completion item"); + assertNotNull(item.getDocumentation(), "Should have documentation"); + // Documentation should contain the property value + String documentation = item.getDocumentation().getRight().getValue(); + assertTrue(documentation.contains("Hello"), + "Documentation should contain the property value 'Hello'"); + } + + @Test + public void testResourceBundleCompletionMultilineValue() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(""); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\""); + assertNotEquals(0, list.getItems().size(), "Completion list should not be empty"); + + // Find the multiline completion item + CompletionItem item = list.getItems().stream() + .filter(i -> i.getKind() == CompletionItemKind.Property && "message.multiline".equals(i.getLabel())) + .findFirst() + .orElse(null); + + assertNotNull(item, "Should find message.multiline completion item"); + assertNotNull(item.getDocumentation(), "Should have documentation"); + // Documentation should contain the multiline property value with markdown formatting + String documentation = item.getDocumentation().getRight().getValue(); + assertTrue(documentation.contains("This is a multiline message"), + "Documentation should contain the multiline property value"); + assertTrue(documentation.contains(" \n"), + "Documentation should contain markdown-formatted newlines (double newlines)"); + // Verify that single \n has been replaced with \n\n + assertFalse(documentation.contains("message.\nIt") || documentation.contains("lines.\nEach"), + "Documentation should not contain single newlines (should be prefixed by 2 spaces)"); + } + + @Test + public void testResourceBundleCompletionWithVariableBundleName() throws Exception { + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + public void test() { + var bundleName = "resources.messages"; + var bundle = ResourceBundle.getBundle(bundleName); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + } + + @Test + public void testResourceBundleCompletionDisabledByPreference() throws Exception { + // Disable resource bundle completion via preference + preferences.setResourceBundleCompletionEnabled(false); + + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(""); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\""); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions (Property kind) + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertTrue(resourceBundleItems.isEmpty(), "Should not have resource bundle key completions when preference is disabled"); + } + + @Test + public void testResourceBundleCompletionNotProvidedAfterComma() throws Exception { + // Test that completion is NOT provided after a comma (getString only takes 1 parameter) + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString("greeting.hello", ); + } + } + """); + + // Request completion after the comma + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting.hello\", "); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + // Should NOT provide resource bundle completions after comma (getString only has 1 parameter) + assertTrue(resourceBundleItems.isEmpty(), "Should not provide resource bundle completions after comma"); + } + + @Test + public void testResourceBundleCompletionNotProvidedAfterClosingParen() throws Exception { + // Test that completion is NOT provided after closing parenthesis + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString("greeting.hello"); + } + } + """); + + // Request completion after the closing parenthesis + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting.hello\")"); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + // Should NOT provide resource bundle completions after closing paren + assertTrue(resourceBundleItems.isEmpty(), "Should not provide resource bundle completions after closing parenthesis"); + } + + @Test + public void testResourceBundleCompletionWithQuotesInComment() throws Exception { + // Test that quotes in comments don't interfere with quote detection + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + /* Comment with "quotes" */ + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + // Should still provide completions (quotes in comment should be ignored) + assertFalse(resourceBundleItems.isEmpty(), "Should provide resource bundle completions despite quotes in comment"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + } + + @Test + public void testResourceBundleCompletionAtEmptyArgumentPosition() throws Exception { + // Test completion at empty argument position (before quotes are typed) + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString(); + } + } + """); + + // Request completion at empty argument position + CompletionList list = requestCompletions(unit, "bundle.getString("); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + // Should provide completions at empty argument position + assertFalse(resourceBundleItems.isEmpty(), "Should provide resource bundle completions at empty argument position"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("error.notfound"), "Should contain error.notfound"); + } + + @Test + public void testResourceBundleCompletionWithLocaleFrench() throws Exception { + // Test that locale detection works with Locale.FRENCH + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.FRENCH); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + + // Verify that French values are prioritized (check documentation) + CompletionItem helloItem = resourceBundleItems.stream() + .filter(item -> "greeting.hello".equals(item.getLabel())) + .findFirst() + .orElse(null); + assertNotNull(helloItem, "Should find greeting.hello completion item"); + + // The documentation should show the French value "Bonjour" if locale detection worked + Object documentation = helloItem.getDocumentation(); + if (documentation instanceof MarkupContent markupContent) { + String value = markupContent.getValue(); + // French value "Bonjour" should be shown when French locale is detected + assertTrue(value.contains("Bonjour"), + "Documentation should contain French value 'Bonjour' when Locale.FRENCH is used, but was: " + value); + } else { + // Documentation might be null or in different format, but item should exist + assertNotNull(documentation, "Completion item should have documentation"); + } + } + + @Test + public void testResourceBundleCompletionWithLocaleFrance() throws Exception { + // Test that locale detection works with Locale.FRANCE (fr_FR) + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.FRANCE); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // France-specific key should be available + assertTrue(labels.contains("greeting.formal"), "Should contain greeting.formal (France-specific)"); + } + + @Test + public void testResourceBundleCompletionWithNewLocale() throws Exception { + // Test that locale detection works with new Locale("fr") + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", new Locale("fr")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + } + + @Test + public void testResourceBundleCompletionWithNewLocaleWithCountry() throws Exception { + // Test that locale detection works with new Locale("fr", "FR") + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", new Locale("fr", "FR")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // France-specific key should be available + assertTrue(labels.contains("greeting.formal"), "Should contain greeting.formal (France-specific)"); + } + + @Test + public void testResourceBundleCompletionWithLocaleOf() throws Exception { + // Test that locale detection works with Locale.of("fr") + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.of("fr")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + } + + @Test + public void testResourceBundleCompletionWithLocaleOfWithCountry() throws Exception { + // Test that locale detection works with Locale.of("fr", "FR") + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.of("fr", "FR")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // France-specific key should be available + assertTrue(labels.contains("greeting.formal"), "Should contain greeting.formal (France-specific)"); + } + + @Test + public void testResourceBundleCompletionWithLocaleForLanguageTag() throws Exception { + // Test that locale detection works with Locale.forLanguageTag("fr-FR") + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.forLanguageTag("fr-FR")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // France-specific key should be available + assertTrue(labels.contains("greeting.formal"), "Should contain greeting.formal (France-specific)"); + } + + @Test + public void testResourceBundleCompletionWithLocaleForLanguageTagLanguageOnly() throws Exception { + // Test that locale detection works with Locale.forLanguageTag("fr") (language only) + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.forLanguageTag("fr")); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + } + + @Test + public void testResourceBundleCompletionWithLocaleFallback() throws Exception { + // Test that when a key doesn't exist in locale-specific file, it falls back to default + // Create a key that only exists in default messages.properties, not in messages_fr.properties + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + import java.util.Locale; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages", Locale.FRENCH); + String value = bundle.getString("app."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"app."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + // app.title and app.version exist in default messages.properties + // They should be available even when using French locale (fallback to default) + assertTrue(labels.contains("app.title"), "Should contain app.title (fallback to default)"); + assertTrue(labels.contains("app.version"), "Should contain app.version (fallback to default)"); + } + + @Test + public void testResourceBundleCompletionWithoutLocale() throws Exception { + // Test that completion works without locale (default behavior) + ICompilationUnit unit = getWorkingCopy( + "src/org/sample/ResourceBundleTest.java", + """ + package org.sample; + import java.util.ResourceBundle; + public class ResourceBundleTest { + private ResourceBundle bundle; + public void test() { + bundle = ResourceBundle.getBundle("resources.messages"); + String value = bundle.getString("greeting."); + } + } + """); + + CompletionList list = requestCompletions(unit, "bundle.getString(\"greeting."); + assertNotNull(list, "Completion list should not be null"); + + // Filter for resource bundle key completions + List resourceBundleItems = list.getItems().stream() + .filter(item -> item.getKind() == CompletionItemKind.Property) + .collect(Collectors.toList()); + + assertFalse(resourceBundleItems.isEmpty(), "Should have resource bundle key completions"); + + List labels = resourceBundleItems.stream() + .map(CompletionItem::getLabel) + .collect(Collectors.toList()); + + assertTrue(labels.contains("greeting.hello"), "Should contain greeting.hello"); + assertTrue(labels.contains("greeting.goodbye"), "Should contain greeting.goodbye"); + // Should not contain France-specific key when no locale is specified + // (unless default locale is fr_FR, but we can't assume that) + } + +} +