diff --git a/org.eclipse.lsp4e.jdt/META-INF/MANIFEST.MF b/org.eclipse.lsp4e.jdt/META-INF/MANIFEST.MF index a94a672c8..7b2ae0f74 100644 --- a/org.eclipse.lsp4e.jdt/META-INF/MANIFEST.MF +++ b/org.eclipse.lsp4e.jdt/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: JDT Integration for LSP4E Bundle-SymbolicName: org.eclipse.lsp4e.jdt;singleton:=true -Bundle-Version: 0.14.2.qualifier +Bundle-Version: 0.14.3.qualifier Export-Package: org.eclipse.lsp4e.jdt Automatic-Module-Name: org.eclipse.lsp4e.jdt Bundle-Activator: org.eclipse.lsp4e.jdt.LanguageServerJdtPlugin diff --git a/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/LSJavaCompletionProposalComputer.java b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/LSJavaCompletionProposalComputer.java index ebf6eb02a..7b1ebd537 100644 --- a/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/LSJavaCompletionProposalComputer.java +++ b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/LSJavaCompletionProposalComputer.java @@ -11,22 +11,51 @@ *******************************************************************************/ package org.eclipse.lsp4e.jdt; +import java.net.URI; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.ISourceRange; +import org.eclipse.jdt.core.ISourceReference; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.SourceRange; +import org.eclipse.jdt.core.ToolFactory; +import org.eclipse.jdt.core.compiler.IScanner; +import org.eclipse.jdt.core.compiler.ITerminalSymbols; +import org.eclipse.jdt.core.compiler.InvalidInputException; import org.eclipse.jdt.ui.text.java.ContentAssistInvocationContext; +import org.eclipse.jdt.ui.text.java.IJavaCompletionProposal; import org.eclipse.jdt.ui.text.java.IJavaCompletionProposalComputer; +import org.eclipse.jdt.ui.text.java.JavaContentAssistInvocationContext; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.Region; import org.eclipse.jface.text.contentassist.ICompletionProposal; import org.eclipse.jface.text.contentassist.IContextInformation; import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.LanguageServers.LanguageServerDocumentExecutor; +import org.eclipse.lsp4e.internal.CancellationSupport; +import org.eclipse.lsp4e.jdt.internal.TextBlockSourceContentMapper; import org.eclipse.lsp4e.operations.completion.LSCompletionProposal; import org.eclipse.lsp4e.operations.completion.LSContentAssistProcessor; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; @SuppressWarnings({ "restriction" }) public class LSJavaCompletionProposalComputer implements IJavaCompletionProposalComputer { @@ -34,8 +63,13 @@ public class LSJavaCompletionProposalComputer implements IJavaCompletionProposal private static final TimeUnit TIMEOUT_UNIT = TimeUnit.MILLISECONDS; private static final long TIMEOUT_LENGTH = 300; + // TODO this should be customizable + private static final Pattern TEXT_BLOCK_LANGUAGE_INDICATOR_COMMENT_PATTERN = Pattern + .compile("\\W*\\s*language=(\\w+)\\W*", + Pattern.CASE_INSENSITIVE); private final LSContentAssistProcessor lsContentAssistProcessor = new LSContentAssistProcessor(false); private @Nullable String javaCompletionSpecificErrorMessage; + private @Nullable LSContentAssistProcessor textBlockContentAssistProcessor; @Override public void sessionStarted() { @@ -45,13 +79,27 @@ public void sessionStarted() { public List computeCompletionProposals(ContentAssistInvocationContext context, IProgressMonitor monitor) { final var viewer = context.getViewer(); - if(viewer == null) + if(viewer == null) { return List.of(); - CompletableFuture future = CompletableFuture.supplyAsync(() -> - lsContentAssistProcessor.computeCompletionProposals(viewer, context.getInvocationOffset())); + } + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + ICompletionProposal[] proposals = lsContentAssistProcessor.computeCompletionProposals(viewer, context.getInvocationOffset()); + monitor.worked(1); + List textBlockProposals = computeTextBlockProposals(context, viewer); + if (!textBlockProposals.isEmpty()) { + List merged = new ArrayList<>(Arrays.asList(proposals)); + for (ICompletionProposal proposal : textBlockProposals) { + merged.add(proposal); + } + return merged.toArray(ICompletionProposal[]::new); + + } + return proposals; + }); try { - return List.of(asJavaProposals(future, context)); + ICompletionProposal[] asJavaProposals = asJavaProposals(future, context); + return List.of(asJavaProposals); } catch (ExecutionException | TimeoutException e) { LanguageServerPlugin.logError(e); javaCompletionSpecificErrorMessage = createErrorMessage(e); @@ -64,6 +112,163 @@ public List computeCompletionProposals(ContentAssistInvocat } } + private List computeTextBlockProposals(ContentAssistInvocationContext context, + ITextViewer viewer) { + IDocument fullDocument = viewer.getDocument(); + if (fullDocument == null || !(context instanceof JavaContentAssistInvocationContext ctx)) { + return List.of(); + } + try { + ICompilationUnit compilationUnit = ctx.getCompilationUnit(); + if (compilationUnit == null + || !(compilationUnit.getElementAt(ctx.getInvocationOffset()) instanceof ISourceReference ref)) { + return List.of(); + } + ISourceRange sourceRange = ref.getSourceRange(); + if (sourceRange == null || !SourceRange.isAvailable(sourceRange)) { + return List.of(); + } + // TODO reuse scanner? + IScanner scanner = ToolFactory.createScanner(true, false, false, false); + scanner.setSource(fullDocument.get().toCharArray());// TODO only create scanner on source range? + scanner.resetTo(sourceRange.getOffset(), sourceRange.getOffset() + sourceRange.getLength()); + int token; + String language = null; + do { + token = scanner.getNextToken(); + switch (token) { + case ITerminalSymbols.TokenNameSEMICOLON -> language = null; + case ITerminalSymbols.TokenNameCOMMENT_LINE, ITerminalSymbols.TokenNameCOMMENT_BLOCK -> { + Matcher matcher = TEXT_BLOCK_LANGUAGE_INDICATOR_COMMENT_PATTERN + .matcher(String.valueOf(scanner.getCurrentTokenSource())); + if (matcher.matches()) { + language = matcher.group(1);// TODO ensure group present with custom regex + } else { + language = null; + } + } + } + } while (token != ITerminalSymbols.TokenNameEOF + && scanner.getCurrentTokenEndPosition() < ctx.getInvocationOffset()); + if (token == ITerminalSymbols.TokenNameTextBlock && language != null) { + TextBlockDocument doc = createDocumentForTextBlockToken(fullDocument, scanner, language); + LanguageServerDocumentExecutor documentExecutor = LanguageServers.forDocument(doc) + .withFilter(capabilities -> capabilities.getCompletionProvider() != null); + markTempDocumentOpened(documentExecutor, doc.getURI(), String.valueOf(scanner.getCurrentTokenSource())); + + try { + int invocationOffsetInContent = TextBlockSourceContentMapper.mapSourceToContent( + scanner.getRawTokenSource(), scanner.getCurrentTokenSource(), + context.getInvocationOffset() - scanner.getCurrentTokenStartPosition()); + if (invocationOffsetInContent != -1) { + ICompletionProposal[] results = getTextBlockContentAssistProcessor() + .computeCompletionProposals(doc, invocationOffsetInContent); + List proposals = new ArrayList<>(results.length); + for (ICompletionProposal proposal : results) { + if (proposal instanceof LSCompletionProposal p) { + proposals.add(new TextBlockProposal(p, doc)); + } + } + return proposals; + } + } finally { + markTempDocumentClosed(documentExecutor, doc.getURI()); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (JavaModelException | InvalidInputException | ExecutionException e) { + LanguageServerPlugin.logError(e); + } + return List.of(); + } + + private LSContentAssistProcessor getTextBlockContentAssistProcessor() { + LSContentAssistProcessor processor = textBlockContentAssistProcessor; + if (processor == null) { + processor = new LSContentAssistProcessor(false); + textBlockContentAssistProcessor = processor; + } + return processor; + } + + private TextBlockDocument createDocumentForTextBlockToken(IDocument fullDocument, IScanner scanner, + String language) { + URI uri = URI.create("none:///" + UUID.randomUUID() + "." + language); + return new TextBlockDocument(fullDocument, + new Region(scanner.getCurrentTokenStartPosition(), + scanner.getCurrentTokenEndPosition() - scanner.getCurrentTokenStartPosition()), + scanner.getRawTokenSource(), scanner.getCurrentTokenSource(), uri); + } + + private void markTempDocumentOpened(LanguageServerDocumentExecutor executor, URI uri, String content) + throws InterruptedException, ExecutionException { + CompletableFuture> notifyOfDocument = executor + .collectAll((w, ls) -> { + VersionedTextDocumentIdentifier id = new VersionedTextDocumentIdentifier(uri.toString(), 0); + w.sendNotification(l -> l.getTextDocumentService().didChange( + new DidChangeTextDocumentParams(id, List.of(new TextDocumentContentChangeEvent(content))))); + return CompletableFuture.completedFuture(null); + }); + new CancellationSupport().execute(notifyOfDocument).get(); + } + + private void markTempDocumentClosed(LanguageServerDocumentExecutor executor, URI uri) { + CompletableFuture> notifyOfDocument = executor.collectAll((w, ls) -> { + VersionedTextDocumentIdentifier id = new VersionedTextDocumentIdentifier(uri.toString(), 0); + w.sendNotification(l -> l.getTextDocumentService().didClose(new DidCloseTextDocumentParams(id))); + return CompletableFuture.completedFuture(null); + }); + new CancellationSupport().execute(notifyOfDocument); + } + + static class TextBlockProposal implements IJavaCompletionProposal { + + private final LSCompletionProposal proposalInTextBlock; + private final IDocument doc; + + public TextBlockProposal(LSCompletionProposal proposalInTextBlock, IDocument doc) { + this.proposalInTextBlock = proposalInTextBlock; + this.doc = doc; + } + + @Override + public @Nullable Point getSelection(IDocument document) { + return null; + } + + @Override + public @Nullable Image getImage() { + return proposalInTextBlock.getImage(); + } + + @Override + public String getDisplayString() { + return proposalInTextBlock.getDisplayString(); + } + + @Override + public @Nullable IContextInformation getContextInformation() { + return proposalInTextBlock.getContextInformation(); + } + + @Override + public @Nullable String getAdditionalProposalInfo() { + return proposalInTextBlock.getAdditionalProposalInfo(); + } + + @Override + public void apply(IDocument document) { + // TODO what if document doesn't match? + proposalInTextBlock.apply(doc); + } + + @Override + public int getRelevance() { + return new LSJavaProposal(proposalInTextBlock).getRelevance(); + } + } + private String createErrorMessage(Exception ex) { return Messages.javaSpecificCompletionError + " : " + (ex.getMessage() != null ? ex.getMessage() : ex.toString()); //$NON-NLS-1$ } @@ -77,20 +282,25 @@ private String createErrorMessage(Exception ex) { * This method wraps around the LSCompletionProposal with a IJavaCompletionProposal, and it sets the relevance * number that JDT uses to sort proposals in a desired order. */ - private ICompletionProposal[] asJavaProposals(CompletableFuture future, ContentAssistInvocationContext context) - throws InterruptedException, ExecutionException, TimeoutException { + private ICompletionProposal[] asJavaProposals(CompletableFuture future, + ContentAssistInvocationContext context) throws InterruptedException, ExecutionException, TimeoutException { ICompletionProposal[] originalProposals = future.get(TIMEOUT_LENGTH, TIMEOUT_UNIT); - - return Arrays.stream(originalProposals).filter(LSCompletionProposal.class::isInstance).map(LSCompletionProposal.class::cast).map(LSJavaProposal::new).toArray(LSJavaProposal[]::new); - + return Arrays.stream(originalProposals) + .filter(p -> p instanceof LSCompletionProposal || p instanceof TextBlockProposal).map(proposal -> { + if (proposal instanceof LSCompletionProposal p) { + return new LSJavaProposal(p); + } + return proposal; + }).toArray(ICompletionProposal[]::new); } @Override public List computeContextInformation(ContentAssistInvocationContext context, IProgressMonitor monitor) { final var viewer = context.getViewer(); - if(viewer == null) + if(viewer == null) { return List.of(); + } IContextInformation[] contextInformation = lsContentAssistProcessor.computeContextInformation(viewer, context.getInvocationOffset()); return contextInformation == null ? List.of() : List.of(contextInformation); } diff --git a/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/TextBlockDocument.java b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/TextBlockDocument.java new file mode 100644 index 000000000..a7c16b5f8 --- /dev/null +++ b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/TextBlockDocument.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * Copyright (c) 2026 Daniel Schmid and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * - Daniel Schmid - Initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.jdt; + +import java.net.URI; + +import org.eclipse.core.runtime.IAdaptable; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.AbstractDocument; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.DefaultLineTracker; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextStore; +import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4e.jdt.internal.TextBlockSourceContentMapper; + +/** + * An {@link IDocument} that is a view of the String content of a text block + * within a Java source file. + */ +class TextBlockDocument extends AbstractDocument implements IAdaptable { + private final URI uri; + private final IDocument fullDocument; + + public TextBlockDocument(IDocument fullDocument, IRegion documentRegion, char[] rawContent, char[] content, + URI uri) { + this.uri = uri; + this.fullDocument = fullDocument; + setTextStore(new ITextStore() { + + @Override + public void set(@Nullable String text) { + replace(0, getLength(), text); + + } + + @Override + public void replace(int offset, int length, @Nullable String text) { + if (text == null) { + // TODO ??? + text = ""; + } + // TODO bounds check + if (offset < 0 || offset + length > content.length) { + LanguageServerPlugin + .logError("range out of bounds in text block, offset=" + offset + ", length=" + length); + return; + } + int newOffset = TextBlockSourceContentMapper.mapContentToSource(rawContent, content, offset); + int newEnd = TextBlockSourceContentMapper.mapContentToSource(rawContent, content, offset + length); + if (newOffset < 0 || newEnd < 0) { + return;//TODO log error? + } + try { + fullDocument.replace(documentRegion.getOffset() + newOffset, newEnd - newOffset, text); + } catch (BadLocationException e) { + LanguageServerPlugin.logError(e); + } + } + + @Override + public int getLength() { + return content.length; + } + + @Override + public String get(int offset, int length) { + return new String(content, offset, length); + } + + @Override + public char get(int offset) { + return content[offset]; + } + }); + setLineTracker(new DefaultLineTracker()); + getTracker().set(new String(content)); + completeInitialization(); + } + + @Override + public @Nullable T getAdapter(Class adapter) { + if (adapter == URI.class) { + return (T) uri; + } + return null; + } + + public URI getURI() { + return uri; + } +} \ No newline at end of file diff --git a/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/TextBlockSourceContentMapper.java b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/TextBlockSourceContentMapper.java new file mode 100644 index 000000000..e2d72b297 --- /dev/null +++ b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/TextBlockSourceContentMapper.java @@ -0,0 +1,340 @@ +/******************************************************************************* + * Copyright (c) 2026 Daniel Schmid and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * - Daniel Schmid - Initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.jdt.internal; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.Region; +import org.eclipse.lsp4e.LanguageServerPlugin; + +/** + * This class is used for mapping positions within a text block in Java source + * code and the content (the text it corresponds to). + *

+ * This works by traversing the source and content together until the position + * is reached and then returning the current position in the other array. + */ +public final class TextBlockSourceContentMapper { + /** + * The source code segment of the text block (part of the .java file) including + * the leading and trailing """ + */ + private final char[] source; + private int currentSourceIndex; + /** + * The index in {@link #source} where content of the text block ends (i.e. where + * the ending """ starts) + */ + private final int sourceEnd; + + /** + * {@link #source} with leading incidential whitespace removed + */ + private final char[] sourceWithoutLeadingWhitespace; + private int currentSourceWithoutLeadingWhitespaceIndex; + + /** + * The logical content/string of the text block + */ + private final char[] content; + private int currentContentIndex; + + /** + * Used only by {@link #skipTrailingSpaces()} + */ + private int lastFoundFutureNonWhitespaceInSourceLine; + + private TextBlockSourceContentMapper(char[] source, char[] content, IRegion contentRegion) { + this.source = source; + this.content = content; + sourceWithoutLeadingWhitespace = String.valueOf(source, contentRegion.getOffset(), contentRegion.getLength()) + .stripIndent().toCharArray(); + + currentSourceIndex = contentRegion.getOffset(); + sourceEnd = contentRegion.getLength(); + currentContentIndex = 0; + currentSourceWithoutLeadingWhitespaceIndex = 0; + } + + private static @Nullable TextBlockSourceContentMapper newInstance(char[] source, char[] content) { + IRegion contentRegion = getContentRegion(source); + if (contentRegion == null) { + return null; + } + return new TextBlockSourceContentMapper(source, content, contentRegion); + } + + private static @Nullable IRegion getContentRegion(char[] source) { + int start; + char lastChar; + for (start = 0; start < source.length;) { + CharacterAndNextIndex current = getCharacterAtAndNextIndex(source, start); + start = current.next(); + lastChar = current.c(); + if (lastChar == '\n') { + break; + } + } + + int end = source.length - 3; + if (start < end) { + return new Region(start, end - start); + } + return null; + } + + public static int mapSourceToContent(char[] source, char[] content, int sourceIndex) { + return mapIndexBetweenSourceAndContent(source, content, sourceIndex, true); + } + + public static int mapContentToSource(char[] source, char[] content, int contentIndex) { + return mapIndexBetweenSourceAndContent(source, content, contentIndex, false); + } + + private static int mapIndexBetweenSourceAndContent(char[] source, char[] content, int goalIndex, + boolean sourceToContent) { + + if (goalIndex > (sourceToContent ? source.length : content.length)) { + return -1; + } + + TextBlockSourceContentMapper mapper = newInstance(source, content); + if (mapper == null) { + return -1; + } + mapper.skipIncidentialWhitespaceAtStartOfLine(); // first line after initial """ + + while (mapper.currentSourceIndex < mapper.sourceEnd && mapper.currentContentIndex < mapper.content.length + && (sourceToContent && mapper.currentSourceIndex < goalIndex + || !sourceToContent && mapper.currentContentIndex < goalIndex)) { + mapper.step(); + } + return sourceToContent ? mapper.currentContentIndex : mapper.currentSourceIndex; + } + + private void step() { + // TODO test escapes properly + + CharacterAndNextIndex currentSource = getCharacterAtAndNextIndex(source, currentSourceIndex); + + if (currentSource.isAbortedByEscapedLineBreak()) {// TODO move to next line first + skipIncidentialWhitespaceAtStartOfLine(); + return; + } + + if (Character.isWhitespace(currentSource.c()) && !Character.isWhitespace(content[currentContentIndex])) { + logWhitespaceMismatch(); + + // increment source until non-whitespace found + // This is a fallback trying to recover if something went out of sync + currentSourceIndex = findNextNonWhitespace(source, currentSourceIndex); + currentSourceWithoutLeadingWhitespaceIndex = findNextNonWhitespace(sourceWithoutLeadingWhitespace, + currentSourceWithoutLeadingWhitespaceIndex); + } else if (!Character.isWhitespace(currentSource.c()) && Character.isWhitespace(content[currentContentIndex])) { + logWhitespaceMismatch(); + + // increment content until non-whitespace found + // This is a fallback trying to recover if something went out of sync + moveToNextNonWhitespaceInContent(); + } else { + // move forward by one character + currentSourceIndex = currentSource.next(); + currentContentIndex++; + currentSourceWithoutLeadingWhitespaceIndex = getCharacterAtAndNextIndex(sourceWithoutLeadingWhitespace, + currentSourceWithoutLeadingWhitespaceIndex).next(); + // line break at source --> skipIncidentialWhitespaceAtStartOfLine + if (currentSource.isActualLineBreakInSourceArray()) { + skipIncidentialWhitespaceAtStartOfLine(); + } else { + skipTrailingSpaces(); + } + } + // check for condition + } + + private void logWhitespaceMismatch() { + LanguageServerPlugin.logWarning( + """ + A whitespace mismatch occured when mapping positions between a text block source and content at source index at source index %d and content index %d + Source: + %s + Content: + %s + """ + .formatted(currentSourceIndex, currentContentIndex, String.valueOf(source), + String.valueOf(content))); + } + + private void skipTrailingSpaces() { + if (currentSourceIndex < lastFoundFutureNonWhitespaceInSourceLine) { + return; + } + // will be run by step() + for (int i = currentSourceIndex; i < sourceEnd; i++) { + // TODO unicode escapes + if (!Character.isWhitespace(source[i])) { + // Prevent unnecessary computation with consecutive calls when a line contains a + // lot of whitespace is followed by a non-whitespace + lastFoundFutureNonWhitespaceInSourceLine = i; + return; + } + if (source[i] == '\r' || source[i] == '\n') { + currentSourceIndex = i; + return; + } + } + currentSourceIndex = sourceEnd; + } + + private void moveToNextNonWhitespaceInContent() { + while (currentContentIndex < content.length && Character.isWhitespace(content[currentContentIndex])) { + currentContentIndex++; + } + } + + private static int findNextNonWhitespace(char[] arr, int currentIndex) { + CharacterAndNextIndex current; + int nextIndex = currentIndex; + do { + currentIndex = nextIndex; + current = getCharacterAtAndNextIndex(arr, currentIndex); + nextIndex = current.next(); + } while (Character.isWhitespace(current.c()) && current.next() < arr.length); + return currentIndex; + } + + private void skipIncidentialWhitespaceAtStartOfLine() { + currentSourceIndex = findStartOfLineAfterIncidentialWhitespace(source, currentSourceIndex, + sourceWithoutLeadingWhitespace, currentSourceWithoutLeadingWhitespaceIndex); + } + + private static int findStartOfLineAfterIncidentialWhitespace(char[] source, int currentSourceIndex, + char[] sourceWithoutLeadingWhitespace, int currentSourceWithoutLeadingWhitespaceIndex) { + int sourceWhitespace = countLeadingWhitespaceAtLine(source, currentSourceIndex); + int strippedWhitespace = countLeadingWhitespaceAtLine(sourceWithoutLeadingWhitespace, + currentSourceWithoutLeadingWhitespaceIndex); + if (strippedWhitespace < sourceWhitespace) { + return skipNCharacters(sourceWhitespace - strippedWhitespace, source, currentSourceIndex); + } + return currentSourceIndex; + } + + private static int skipNCharacters(int n, char[] arr, int currentIndex) { + for (int i = 0; i < n && currentIndex < arr.length; i++) { + CharacterAndNextIndex current = getCharacterAtAndNextIndex(arr, currentIndex); + currentIndex = current.next(); + } + return currentIndex; + } + + private static int countLeadingWhitespaceAtLine(char[] arr, int lineStartIndex) { + int count = 0; + int i = lineStartIndex; + while (i < arr.length) { + CharacterAndNextIndex current = getCharacterAtAndNextIndex(arr, i); + char c = current.c(); + if (!Character.isWhitespace(c) || c == '\n') { + return count; + } + i = current.next(); + count++; + } + return count; + } + + /** + * Gets the content character at a specific index in the content from a source + * array as well as the index of the next character. + * + * In case of an actual line break (e.g. '\n' but not '\' followed by 'n') in + * the source code, this will always return '\n' even if the line break is not + * present in the content. This situation is indicated by flags in the returned + * value. + * + * The next index may be {@code arr.length} when the end of the array is + * reached. + * + * @param arr + * The source array + * @param currentIndex + * The index of the character to get + * @return + */ + private static CharacterAndNextIndex getCharacterAtAndNextIndex(char[] arr, int currentIndex) { + if (currentIndex >= arr.length) { + return new CharacterAndNextIndex('\0', arr.length, false, false); + } + + char currentCharacter = arr[currentIndex]; + int nextIndex = currentIndex + 1; + + if ((currentCharacter != '\\') || (nextIndex >= arr.length)) { + if (currentCharacter == '\r' && nextIndex < arr.length && arr[currentCharacter] == '\n') { + nextIndex++; + } + return new CharacterAndNextIndex(currentCharacter, nextIndex, false, + currentCharacter == '\n' || currentCharacter == '\r'); + } + + char nextCharacter = arr[nextIndex]; + switch (nextCharacter) { + case 'u' -> { + // TODO need to execute remaining logic as well here since lexing is before + // parsing - also possible twice after each other + if (nextIndex + 4 < arr.length) { + try { + int codepoint = Integer.parseInt(String.valueOf(arr, nextIndex + 1, 4)); + return new CharacterAndNextIndex((char) codepoint, nextIndex + 5, false, false); + } catch (NumberFormatException e) { + return new CharacterAndNextIndex(' ', nextIndex + 5, false, false); + } + } + return new CharacterAndNextIndex('\0', arr.length, false, false); + } + case '\n' -> { + return new CharacterAndNextIndex('\n', nextIndex + 1, true, true); + } + case '\r' -> { + if (nextIndex + 1 < arr.length && arr[nextIndex + 1] == '\n') { + nextIndex++; + } + return new CharacterAndNextIndex('\n', nextIndex + 1, true, true); + } + default -> { + return new CharacterAndNextIndex(String.valueOf(arr, currentIndex, 2).translateEscapes().charAt(0), + nextIndex + 1, false, false); + } + } + } + + /** + * The result of + * {@link TextBlockSourceContentMapper#getCharacterAtAndNextIndex(char[], int)}. + * + * @param c + * The character + * @param next + * The index of the next character + * @param isAbortedByEscapedLineBreak + * Whether a \n was returned because + * of a line break which is not + * included in the content (a line in + * the source code ending with a + * backslash) + * @param isActualLineBreakInSourceArray + * Whether this is a real line break + * within the source code + */ + record CharacterAndNextIndex(char c, int next, boolean isAbortedByEscapedLineBreak, + boolean isActualLineBreakInSourceArray) { + } +} diff --git a/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/package-info.java b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/package-info.java new file mode 100644 index 000000000..13b50862e --- /dev/null +++ b/org.eclipse.lsp4e.jdt/src/org/eclipse/lsp4e/jdt/internal/package-info.java @@ -0,0 +1,6 @@ +@NonNullByDefault({ ARRAY_CONTENTS, PARAMETER, RETURN_TYPE, FIELD, TYPE_BOUND, TYPE_ARGUMENT }) +package org.eclipse.lsp4e.jdt.internal; + +import static org.eclipse.jdt.annotation.DefaultLocation.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java index 9905e114d..7d8c15a7c 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LSPEclipseUtils.java @@ -49,6 +49,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +import com.google.common.primitives.Chars; import org.eclipse.core.filebuffers.FileBuffers; import org.eclipse.core.filebuffers.IFileBuffer; import org.eclipse.core.filebuffers.ITextFileBuffer; @@ -167,8 +168,6 @@ import org.eclipse.ui.texteditor.IDocumentProvider; import org.eclipse.ui.texteditor.ITextEditor; -import com.google.common.primitives.Chars; - /** * Some utility methods to convert between Eclipse and LS-API types */ @@ -1471,6 +1470,16 @@ public static List getDocumentContentTypes(IDocument document) { } String fileName = getFileName(buffer); + if (contentTypes.isEmpty() && buffer == null && fileName == null) { + URI uri = Adapters.adapt(document, URI.class); + if (uri != null) { + String path = uri.getPath(); + if (path.contains("/")) { //$NON-NLS-1$ + path = path.substring(path.lastIndexOf('/') + 1); + } + fileName = path; + } + } if (fileName != null) { try (var contents = new DocumentInputStream(document)) { contentTypes.addAll(List.of(Platform.getContentTypeManager().findContentTypesFor(contents, fileName))); diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java index 0ceda9b93..f9188e4a2 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java @@ -31,6 +31,9 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import com.google.common.base.Functions; +import com.google.common.base.Strings; +import org.eclipse.core.runtime.Adapters; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.text.BadLocationException; @@ -64,9 +67,6 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.ui.texteditor.ITextEditor; -import com.google.common.base.Functions; -import com.google.common.base.Strings; - public class LSContentAssistProcessor implements IContentAssistProcessor { private static final ICompletionProposal[] NO_COMPLETION_PROPOSALS = new ICompletionProposal[0]; @@ -114,10 +114,17 @@ public LSContentAssistProcessor(boolean errorAsCompletionItem, boolean incomplet @Override public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int offset) { IDocument document = viewer.getDocument(); + return computeCompletionProposals(document, offset); + } + + /** + * @noreference + */ + // TODO Don't make this available to clients other than LSP4e + public final ICompletionProposal[] computeCompletionProposals(@Nullable IDocument document, int offset) { if (document == null) { return NO_COMPLETION_PROPOSALS; } - final @Nullable Character triggerChar; final Position completionPosition; try { @@ -131,7 +138,7 @@ public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer, int return NO_COMPLETION_PROPOSALS; } - URI uri = LSPEclipseUtils.toUri(document); + URI uri = LSPEclipseUtils.toUri(document);//TOOD requires store.setValue("org.eclipse.lsp4e.resourceFallback.enabled", true); for text block completions if (uri == null) { return NO_COMPLETION_PROPOSALS; }