/************************************************************************************ * This file is part of AndroidIDE. * * * * AndroidIDE is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * AndroidIDE is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with AndroidIDE. If not, see . * **************************************************************************************/ package com.itsaky.androidide.lsp; import android.content.DialogInterface; import android.text.SpannableStringBuilder; import android.text.style.ForegroundColorSpan; import android.view.View; import androidx.core.content.ContextCompat; import androidx.transition.ChangeBounds; import androidx.transition.Fade; import androidx.transition.TransitionManager; import androidx.transition.TransitionSet; import com.blankj.utilcode.util.FileIOUtils; import com.blankj.utilcode.util.FileUtils; import com.blankj.utilcode.util.ThreadUtils; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.firebase.crashlytics.FirebaseCrashlytics; import com.google.gson.Gson; import com.itsaky.androidide.EditorActivity; import com.itsaky.androidide.R; import com.itsaky.androidide.adapters.DiagnosticsAdapter; import com.itsaky.androidide.adapters.SearchListAdapter; import com.itsaky.androidide.app.StudioApp; import com.itsaky.androidide.databinding.FragmentEditorBinding; import com.itsaky.androidide.databinding.LayoutDiagnosticInfoBinding; import com.itsaky.androidide.fragments.EditorFragment; import com.itsaky.androidide.fragments.sheets.ProgressSheet; import com.itsaky.androidide.interfaces.EditorActivityProvider; import com.itsaky.androidide.models.DiagnosticGroup; import com.itsaky.androidide.models.SearchResult; import com.itsaky.androidide.tasks.TaskExecutor; import com.itsaky.androidide.utils.DialogUtils; import com.itsaky.androidide.utils.LSPUtils; import com.itsaky.androidide.utils.Logger; import com.itsaky.lsp.SemanticHighlight; import com.itsaky.lsp.services.IDELanguageClient; import com.itsaky.lsp.services.IDELanguageServer; import com.itsaky.toaster.Toaster; import io.github.rosemoe.editor.text.Content; import io.github.rosemoe.editor.widget.CodeEditor; import java.io.File; import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.eclipse.lsp4j.CodeAction; import org.eclipse.lsp4j.Command; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; import org.eclipse.lsp4j.LogTraceParams; import org.eclipse.lsp4j.MessageParams; import org.eclipse.lsp4j.ParameterInformation; import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.ShowDocumentParams; import org.eclipse.lsp4j.ShowDocumentResult; import org.eclipse.lsp4j.SignatureHelp; import org.eclipse.lsp4j.SignatureInformation; import org.eclipse.lsp4j.TextEdit; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.SetTraceParams; import org.eclipse.lsp4j.ApplyWorkspaceEditResponse; import org.eclipse.lsp4j.WorkDoneProgressCreateParams; import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.ConfigurationParams; import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ApplyWorkspaceEditParams; import org.eclipse.lsp4j.MessageActionItem; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.ShowMessageRequestParams; import org.eclipse.lsp4j.UnregistrationParams; /** * AndroidIDE specific implementation of the LanguageClient */ public class IDELanguageClientImpl implements IDELanguageClient { protected static final Gson gson = new Gson(); protected static final Logger LOG = Logger.instance("AbstractLanguageClient"); public static final int DIAGNOSTIC_TRANSITION_DURATION = 80; private final Map> diagnostics = new HashMap<>(); private static IDELanguageClientImpl mInstance; protected EditorActivityProvider activityProvider; private boolean isConnected; private IDELanguageClientImpl (EditorActivityProvider provider) { setActivityProvider(provider); } public static IDELanguageClientImpl getInstance () { if (mInstance == null) { throw new IllegalStateException ("Client not initialized"); } return mInstance; } public static IDELanguageClientImpl initialize (EditorActivityProvider provider) { if (mInstance != null) { throw new IllegalStateException ("Client is already initialized"); } mInstance = new IDELanguageClientImpl (provider); return getInstance(); } public static boolean isInitialized () { return mInstance != null; } public void setActivityProvider(EditorActivityProvider provider) { this.activityProvider = provider; } /** * Are we connected to the server? */ public boolean isConnected() { return isConnected; } protected EditorActivity activity() { if (activityProvider == null) return null; return activityProvider.provide(); } @Override public void semanticHighlights(SemanticHighlight highlights) { final File file = new File(URI.create(highlights.uri)); final EditorFragment editor = activity().getPagerAdapter().findEditorByFile(file); if(editor != null) { editor.getEditor().setSemanticHighlights(highlights); } } public void showDiagnostic(Diagnostic diagnostic, final CodeEditor editor) { if(activity() == null || activity().getDiagnosticBinding() == null) { hideDiagnostics(); return; } if(diagnostic == null) { hideDiagnostics(); return; } final LayoutDiagnosticInfoBinding binding = activity().getDiagnosticBinding(); binding.getRoot().setText(diagnostic.getMessage()); binding.getRoot().setVisibility(View.VISIBLE); final float[] cursor = editor.getCursorPosition(); float x = editor.updateCursorAnchor() - (binding.getRoot().getWidth() / 2); float y = activity().getBinding().editorAppBarLayout.getHeight() + (cursor[0] - editor.getRowHeight() - editor.getOffsetY() - binding.getRoot().getHeight()); binding.getRoot().setX(x); binding.getRoot().setY(y); activity().positionViewWithinScreen(binding.getRoot(), x, y); } /** * Shows the diagnostic at the bottom of the screen (just above the status text) * and requests code actions from language server * * @param diagnostic The diagnostic to show * @param editor The CodeEditor that requested */ public void showDiagnosticAtBottom(final File file, final Diagnostic diagnostic, final CodeEditor editor) { if(activity() == null || activity().getPagerAdapter() == null || file == null || diagnostic == null) { hideBottomDiagnosticView(file); return; } final EditorFragment frag = activity().getPagerAdapter().findEditorByFile(file); if(frag == null || frag.getBinding() == null) { hideBottomDiagnosticView(file); return; } final FragmentEditorBinding binding = frag.getBinding(); binding.diagnosticTextContainer.setVisibility(View.VISIBLE); binding.diagnosticText.setVisibility(View.VISIBLE); binding.diagnosticText.setClickable(false); binding.diagnosticText.setText(diagnostic.getMessage()); final CompletableFuture >> future = editor.codeActions(Collections.singletonList(diagnostic)); if(future == null) { hideBottomDiagnosticView(file); return; } future.whenComplete((a, b) -> { final Throwable error = b; if(a == null || a.isEmpty()) { hideBottomDiagnosticView(file); return; } final List actions = a.stream().filter(e -> e.isRight()).map (e -> e.getRight()).collect(Collectors.toList()); if(actions == null || actions.isEmpty()) { hideBottomDiagnosticView(file); return; } ThreadUtils.runOnUiThread(() -> { final SpannableStringBuilder sb = new SpannableStringBuilder(); sb.append(activity().getString(R.string.msg_fix_diagnostic), new ForegroundColorSpan(ContextCompat.getColor(activity(), R.color.secondaryColor)), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); sb.append(" "); sb.append(diagnostic.getMessage()); binding.diagnosticText.setText(sb); binding.diagnosticText.setClickable(true); binding.diagnosticText.setOnClickListener(v -> { showAvailableQuickfixes(editor, actions); }); }); }); } public void hideDiagnostics() { if(activity() == null || activity().getDiagnosticBinding() == null) { return; } activity().getDiagnosticBinding().getRoot().setVisibility(View.GONE); } private void hideBottomDiagnosticView(final File file) { if(activity() == null || activity().getPagerAdapter() == null || file == null) { return; } final EditorFragment frag = activity().getPagerAdapter().findEditorByFile(file); if(frag == null || frag.getBinding() == null) { return; } frag.getBinding().diagnosticTextContainer.setVisibility(View.GONE); frag.getBinding().diagnosticText.setVisibility(View.GONE); frag.getBinding().diagnosticText.setClickable(false); } /** * Called by {@link io.github.rosemoe.editor.widget.CodeEditor CodeEditor} to show signature help in EditorActivity */ public void showSignatureHelp(SignatureHelp signature, File file) { if(signature == null || signature.getSignatures() == null) { hideSignatureHelp(); return; } SignatureInformation info = signatureWithMostParams(signature); if(info == null) return; activity().getBinding().symbolText.setText(formatSignature(info, signature.getActiveParameter())); final EditorFragment frag = activity().getPagerAdapter().findEditorByFile(file); if(frag != null) { final CodeEditor editor = frag.getEditor(); final float[] cursor = editor.getCursorPosition(); float x = editor.updateCursorAnchor() - (activity().getBinding().symbolText.getWidth() / 2); float y = activity().getBinding().editorAppBarLayout.getHeight() + (cursor[0] - editor.getRowHeight() - editor.getOffsetY() - activity().getBinding().symbolText.getHeight()); TransitionManager.beginDelayedTransition(activity().getBinding().getRoot()); activity().getBinding().symbolText.setVisibility(View.VISIBLE); activity().positionViewWithinScreen(activity().getBinding().symbolText, x, y); } } /** * Called by {@link io.github.rosemoe.editor.widget.CodeEditor CodeEditor} to hide signature help in EditorActivity */ public void hideSignatureHelp() { if(activity() == null) return; TransitionManager.beginDelayedTransition(activity().getBinding().getRoot()); activity().getBinding().symbolText.setVisibility(View.GONE); } /** * Find the signature with most parameters * * @param signature The SignatureHelp provided by @{link IDELanguageServer} */ private SignatureInformation signatureWithMostParams(SignatureHelp signature) { SignatureInformation signatureWithMostParams = null; int mostParamCount = 0; final List signatures = signature.getSignatures(); for(int i=0;i params = signature.getParameters(); for(int i=0;i locations) { // Cannot show anything if the activity() is null if(activity() == null) { return; } boolean error = locations == null || locations.isEmpty(); activity().handleSearchResultVisibility(error); if(error) { activity().setSearchResultAdapter (new SearchListAdapter(null, null, null)); return; } final Map> results = new HashMap<>(); for(int i=0;i matches = results.containsKey(file) ? results.get(file) : new ArrayList<>(); matches.add( new SearchResult( loc.getRange(), file, content.getLineString(loc.getRange().getStart().getLine()), content.subContent( loc.getRange().getStart().getLine(), loc.getRange().getStart().getCharacter(), loc.getRange().getEnd().getLine(), loc.getRange().getEnd().getCharacter() ).toString() ) ); results.put(file, matches); } catch (Throwable th) { } } activity().handleSearchResults(results); } /** * Called by {@link io.github.rosemoe.editor.widget.CodeEditor CodeEditor} to show location links in EditorActivity. * These location links are mapped as {@link org.eclipse.lsp4j.Location Location} and then {@link #showLocations(List) } is called. */ public void showLocationLinks(List locations) { if(locations == null || locations.size() <= 0) { return; } showLocations(locations .stream() .filter(l -> l != null) .map(l -> asLocation(l)) .filter(l -> l != null) .collect(Collectors.toList()) ); } /** * Perform the given {@link CodeAction} * * @param editor The {@link CodeEditor} that invoked the code action request. * This is required to reduce the time finding the code action from the edits. * @param action The action to perform */ public void performCodeAction(CodeEditor editor, CodeAction action) { if(activity() == null || editor == null || action == null) { StudioApp.getInstance().toast(R.string.msg_cannot_perform_fix, Toaster.Type.ERROR); return; } final ProgressSheet progress = new ProgressSheet(); progress.setSubMessageEnabled(false); progress.setWelcomeTextEnabled(false); progress.setCancelable(false); progress.setMessage(activity().getString(R.string.msg_performing_fixes)); progress.show(activity().getSupportFragmentManager(), "quick_fix_progress"); new TaskExecutor().executeAsyncProvideError(() -> performCodeActionAsync(editor, action), (a, b) -> { final Boolean complete = a; final Throwable error = b; progress.dismiss(); if(complete == null || error != null || !complete.booleanValue()) { StudioApp.getInstance().toast(R.string.msg_cannot_perform_fix, Toaster.Type.ERROR); return; } }); } /** * Usually called {@link CodeEditor} to show a specific document in EditorActivity and select the specified range */ @Override public CompletableFuture showDocument(ShowDocumentParams params) { ShowDocumentResult result = new ShowDocumentResult(); boolean success = false; if(activity() == null) { result.setSuccess(success); return CompletableFuture.completedFuture(result); } if(params != null && params.getUri() != null && params.getSelection() != null) { File file = new File(URI.create(params.getUri())); if(file.exists() && file.isFile() && FileUtils.isUtf8(file)) { final Range range = params.getSelection(); EditorFragment frag = activity().getPagerAdapter().getFrag(activity().getBinding().tabs.getSelectedTabPosition()); if(frag != null && frag.getFile() != null && frag.getEditor() != null && frag.getFile().getAbsolutePath().equals(file.getAbsolutePath())) { if(LSPUtils.isEqual(range.getStart(), range.getEnd())) { frag.getEditor().setSelection(range.getStart().getLine(), range.getStart().getCharacter()); } else { frag.getEditor().setSelectionRegion(range.getStart().getLine(), range.getStart().getCharacter(), range.getEnd().getLine(), range.getEnd().getCharacter()); } } else { activity().openFileAndSelect(file, range); } success = true; } } result.setSuccess(success); return CompletableFuture.completedFuture(result); } @Override public void telemetryEvent(Object params) { FirebaseCrashlytics.getInstance().log("LanguageServer[" + this.getClass().getName() + "][TelemetryEvent]\n" + params.toString()); } @Override public void logMessage(MessageParams params) { // No logs } @Override public void logTrace(LogTraceParams params) { } private Location asLocation(LocationLink link) { if(link == null || link.getTargetRange() == null || link.getTargetUri() == null) return null; final Location location = new Location(); location.setUri(link.getTargetUri()); location.setRange(link.getTargetRange()); return location; } private List mapAsGroup(Map> diags) { List groups = new ArrayList<>(); if(diags == null || diags.size() <= 0) return groups; for(File file : diags.keySet()) { List fileDiags = diags.get(file); if(fileDiags == null || fileDiags.size() <= 0) continue; DiagnosticGroup group = new DiagnosticGroup(R.drawable.ic_language_java, file, fileDiags); groups.add(group); } return groups; } private void showAvailableQuickfixes (CodeEditor editor, List actions) { final MaterialAlertDialogBuilder builder = DialogUtils.newMaterialDialogBuilder (activity ()); builder.setTitle(R.string.msg_code_actions); builder.setItems(asArray(actions), (d, w) -> { final DialogInterface dialog = d; final int which = w; dialog.dismiss(); hideDiagnostics(); hideBottomDiagnosticView(editor.getFile()); performCodeAction(editor, actions.get(which)); }); builder.show(); } private CharSequence[] asArray(List actions) { final String[] arr = new String[actions.size()]; for(int i=0;i> edits = action.getEdit().getChanges(); if(edits == null || edits.isEmpty()) { return Boolean.FALSE; } for(Map.Entry> entry : edits.entrySet()) { final File file = new File(URI.create(entry.getKey())); if(!file.exists()) continue; for(TextEdit edit : entry.getValue()) { final String editorFilepath = editor.getFile() == null ? "" : editor.getFile().getAbsolutePath(); if(file.getAbsolutePath().equals(editorFilepath)) { // Edit is in the same editor which requested the code action editInEditor(editor, edit); } else { EditorFragment openedFrag = activity().getPagerAdapter().findEditorByFile(file); if(openedFrag != null && openedFrag.getEditor() != null) { // Edit is in another 'opened' file editInEditor(openedFrag.getEditor(), edit); } else { // Edit is in some other file which is not opened // We should open that file and perform the edit openedFrag = activity().openFile(file); if(openedFrag != null && openedFrag.getEditor() != null) { editInEditor(openedFrag.getEditor(), edit); } } } } } return Boolean.TRUE; } private void editInEditor (final CodeEditor editor, final TextEdit edit) { final Range range = edit.getRange(); final int startLine = range.getStart().getLine(); final int startCol = range.getStart().getCharacter(); final int endLine = range.getEnd().getLine(); final int endCol = range.getEnd().getCharacter(); activity().runOnUiThread(() -> { if(startLine == endLine && startCol == endCol) { editor.getText().insert(startLine, startCol, edit.getNewText()); } else { editor.getText().replace(startLine, startCol, endLine, endCol, edit.getNewText()); } }); } public DiagnosticsAdapter newDiagnosticsAdapter() { return new DiagnosticsAdapter(mapAsGroup(this.diagnostics), activity()); } /************************************************** UNUSED METHODS **************************************************/ @Override public CompletableFuture applyEdit(ApplyWorkspaceEditParams p1) { return null; } @Override public CompletableFuture> configuration(ConfigurationParams p1) { return null; } @Override public CompletableFuture createProgress(WorkDoneProgressCreateParams p1) { return null; } @Override public void notifyProgress(ProgressParams p1) { } @Override public CompletableFuture refreshCodeLenses() { return null; } @Override public CompletableFuture refreshSemanticTokens() { return null; } @Override public CompletableFuture registerCapability(RegistrationParams p1) { return null; } @Override public void setTrace(SetTraceParams p1) { } @Override public void showMessage(MessageParams p1) { } @Override public CompletableFuture showMessageRequest(ShowMessageRequestParams p1) { return null; } @Override public CompletableFuture unregisterCapability(UnregistrationParams p1) { return null; } @Override public CompletableFuture> workspaceFolders() { return null; } }