From df25fc369f7a0a03c97bd816465c22493bb80fbd Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 5 Mar 2026 15:14:53 +0100 Subject: [PATCH 1/2] better quickfixes --- .../requests/CodeActionRequest.java | 195 +++++++++++++----- .../tests/LspNativeFeaturesTests.java | 111 ++++++++++ 2 files changed, 256 insertions(+), 50 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java index 17a7c1dfe..559741246 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java @@ -10,7 +10,6 @@ import de.peeeq.wurstscript.attributes.names.FuncLink; import de.peeeq.wurstscript.attributes.names.NameLink; import de.peeeq.wurstscript.attributes.names.TypeLink; -import de.peeeq.wurstscript.parser.WPos; import de.peeeq.wurstscript.types.WurstType; import de.peeeq.wurstscript.types.WurstTypeClassOrInterface; import de.peeeq.wurstscript.types.WurstTypeUnknown; @@ -38,13 +37,15 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; /** * */ public class CodeActionRequest extends UserRequest>> { - private final CodeActionParams params; + private static final String CONSTANT_LOCAL_LET_WARNING = "Constant local variables should be defined using 'let'."; + private static final String UNUSED_IMPORT_WARNING_SUFFIX = " is never used"; private final WFile filename; private final String buffer; private final int line; @@ -52,8 +53,6 @@ public class CodeActionRequest extends UserRequest diagnostics; public CodeActionRequest(CodeActionParams params, BufferManager bufferManager) { - this.params = params; - TextDocumentIdentifier textDocument = params.getTextDocument(); this.filename = WFile.create(textDocument.getUri()); this.buffer = bufferManager.getBuffer(textDocument); @@ -85,43 +84,139 @@ public List> execute(ModelManager modelManager) { return Collections.emptyList(); } + List> actions = new ArrayList<>(); + actions.addAll(makeWarningQuickFixes(e.get())); if (e.get() instanceof ExprNewObject) { ExprNewObject enew = (ExprNewObject) e.get(); ConstructorDef constructorDef = enew.attrConstructorDef(); if (constructorDef == null) { - return handleMissingClass(modelManager, enew.getTypeName()); + actions.addAll(handleMissingClass(modelManager, enew.getTypeName())); + return actions; } } else if (e.get() instanceof FuncRef) { FuncRef fr = (FuncRef) e.get(); FuncLink fd = fr.attrFuncLink(); if (fd == null) { - return handleMissingFunction(modelManager, fr); + actions.addAll(handleMissingFunction(modelManager, fr)); + return actions; } } else if (e.get() instanceof NameRef) { NameRef nr = (NameRef) e.get(); NameLink nd = nr.attrNameLink(); if (nd == null) { - return handleMissingName(modelManager, nr); + actions.addAll(handleMissingName(modelManager, nr)); + return actions; } } else if (e.get() instanceof TypeExprSimple) { TypeExprSimple nr = (TypeExprSimple) e.get(); TypeDef nd = nr.attrTypeDef(); if (nd == null) { - return handleMissingType(modelManager, nr.getTypeName()); + actions.addAll(handleMissingType(modelManager, nr.getTypeName())); + return actions; } } else if (e.get() instanceof ModuleUse) { ModuleUse mu = (ModuleUse) e.get(); ModuleDef def = mu.attrModuleDef(); if (def == null) { - return handleMissingModule(modelManager, mu.getModuleNameId().getName()); + actions.addAll(handleMissingModule(modelManager, mu.getModuleNameId().getName())); + return actions; } } - return Collections.emptyList(); + return actions; + } + + private List> makeWarningQuickFixes(Element e) { + List> result = new ArrayList<>(); + + if (hasDiagnosticMessage(msg -> msg.contains(CONSTANT_LOCAL_LET_WARNING))) { + findNearest(e, LocalVarDef.class).ifPresent(localVar -> { + int line0 = localVar.attrSource().getLine() - 1; + int startChar = Math.max(0, localVar.attrSource().getStartColumn() - 1); + int varPos = findKeywordInLine(line0, startChar, "var"); + if (varPos >= 0) { + TextEdit edit = new TextEdit( + new Range(new Position(line0, varPos), new Position(line0, varPos + 3)), + "let" + ); + result.add(Either.forRight(makeQuickFix( + "Use 'let' for constant local", + workspaceEdit(filename.getUriString(), edit) + ))); + } + }); + } + + if (hasDiagnosticMessage(msg -> msg.startsWith("The import ") && msg.endsWith(UNUSED_IMPORT_WARNING_SUFFIX))) { + findNearest(e, WImport.class).ifPresent(imp -> { + int line0 = imp.attrSource().getLine() - 1; + TextEdit edit = new TextEdit(lineDeletionRange(line0), ""); + result.add(Either.forRight(makeQuickFix( + "Remove unused import " + imp.getPackagename(), + workspaceEdit(filename.getUriString(), edit) + ))); + }); + } + + return result; + } + + private boolean hasDiagnosticMessage(Predicate matcher) { + return diagnostics.stream() + .map(Diagnostic::getMessage) + .filter(msg -> msg != null && !msg.isEmpty()) + .anyMatch(matcher); + } + + private int findKeywordInLine(int line0, int startChar, String keyword) { + String[] lines = buffer.split("\\r?\\n", -1); + if (line0 < 0 || line0 >= lines.length) { + return -1; + } + String l = lines[line0]; + if (startChar >= l.length()) { + return -1; + } + int idx = l.indexOf(keyword, startChar); + if (idx < 0) { + return -1; + } + if (idx > 0 && Character.isJavaIdentifierPart(l.charAt(idx - 1))) { + return -1; + } + int end = idx + keyword.length(); + if (end < l.length() && Character.isJavaIdentifierPart(l.charAt(end))) { + return -1; + } + return idx; + } + + private Range lineDeletionRange(int line0) { + String[] lines = buffer.split("\\r?\\n", -1); + if (line0 < 0 || line0 >= lines.length) { + Position p = new Position(Math.max(line0, 0), 0); + return new Range(p, p); + } + Position start = new Position(line0, 0); + if (line0 + 1 < lines.length) { + return new Range(start, new Position(line0 + 1, 0)); + } + return new Range(start, new Position(line0, lines[line0].length())); + } + + private Optional findNearest(Element e, Class clazz) { + Element elem = e; + while (elem != null) { + if (clazz.isInstance(elem)) { + return Optional.of(clazz.cast(elem)); + } + elem = elem.getParent(); + } + return Optional.empty(); } private List> handleMissingName(ModelManager modelManager, NameRef nr) { @@ -195,8 +290,8 @@ private List> makeCreateFunctionQuickfix(FuncRef fr) class M implements FuncRef.MatcherVoid { private List parameterTypes = Collections.emptyList(); private List parameterNames = Collections.emptyList(); - private int line; - private int indent; + private int insertLine0; + private int declarationIndentLevels = 0; private final WFile targetFile = filename; private String receiverType = ""; private WurstType returnType = WurstTypeVoid.instance(); @@ -205,44 +300,32 @@ class M implements FuncRef.MatcherVoid { @Override public void case_ExprFuncRef(ExprFuncRef e) { - getInsertPos(fr); + setPackageInsertPos(fr); } @Override public void case_Annotation(Annotation annotation) { isAnnotation = true; - getInsertPos(fr); + setPackageInsertPos(fr); } - private void getInsertPos(Element fr) { - Element elem = fr; - while (elem != null) { - if (elem instanceof FunctionLike) { - WPos source = elem.attrSource(); - line = source.getEndLine(); - indent = source.getStartColumn() - 1; - break; - } else if (elem instanceof WPackage) { - WPos source = elem.attrSource(); - line = source.getEndLine(); - indent = 0; - break; - } else if (elem instanceof FuncDefs) { - FuncDefs funcDefs = (FuncDefs) elem; - if (!funcDefs.isEmpty()) { - WPos source = Utils.getLast(funcDefs).attrSource(); - line = source.getEndLine(); - indent = source.getStartColumn() - 1; - break; - } - } else if (elem instanceof NamedScope) { - WPos source = elem.attrSource(); - line = source.getEndLine(); - indent = source.getStartColumn() - 1 + 4; - break; - } - elem = elem.getParent(); + private void setPackageInsertPos(Element where) { + PackageOrGlobal p = where.attrNearestPackage(); + if (p instanceof WPackage) { + // Insert right before endpackage. + insertLine0 = Math.max(0, p.attrSource().getEndLine() - 1); + } else { + insertLine0 = 0; } + declarationIndentLevels = 0; + } + + private void setClassInsertPos(ClassOrInterface classDef) { + // Insert right after class declaration. This is stable and avoids placing the stub inside method bodies. + insertLine0 = classDef.attrSource().getLine(); + int memberIndentColumns = Math.max(0, classDef.attrSource().getStartColumn() - 1) + 4; + CompilationUnitInfo.IndentationMode indentationMode = fr.attrCompilationUnit().getCuInfo().getIndentationMode(); + declarationIndentLevels = indentationMode.countIndents(memberIndentColumns); } @Override @@ -259,10 +342,10 @@ public void case_ExprMemberMethodDot(ExprMemberMethodDot e) { private void case_Member(ExprMemberMethod e) { WurstType leftType = e.getLeft().attrTyp(); if (leftType instanceof WurstTypeClassOrInterface) { - getInsertPos(((WurstTypeClassOrInterface) leftType).getDef().getMethods()); + setClassInsertPos(((WurstTypeClassOrInterface) leftType).getDef()); } else { - getInsertPos(e); - receiverType = leftType + "."; + setPackageInsertPos(e); + receiverType = leftType.toPrettyString() + "."; } addParametertypes(e.getArgs()); returnType = e.attrExpectedTyp(); @@ -306,14 +389,24 @@ private String deriveParameterName(Expr expr) { @Override public void case_ExprFunctionCall(ExprFunctionCall e) { - getInsertPos(fr); + Optional classDef = findNearest(e, ClassDef.class); + if (classDef.isPresent()) { + setClassInsertPos(classDef.get()); + } else { + setPackageInsertPos(fr); + } addParametertypes(e.getArgs()); } - public void indent(StringBuilder sb) { + public void appendDeclarationIndent(StringBuilder sb) { + CompilationUnitInfo.IndentationMode indentationMode = fr.attrCompilationUnit().getCuInfo().getIndentationMode(); + indentationMode.appendIndent(sb, declarationIndentLevels); + } + + public void appendBodyIndent(StringBuilder sb) { CompilationUnitInfo.IndentationMode indentationMode = fr.attrCompilationUnit().getCuInfo().getIndentationMode(); - indentationMode.appendIndent(sb, indentationMode.countIndents(indent) + 1); + indentationMode.appendIndent(sb, declarationIndentLevels + 1); } } M m = new M(); @@ -321,7 +414,7 @@ public void indent(StringBuilder sb) { String title = "Create function " + fr.getFuncName(); StringBuilder code = new StringBuilder("\n"); - m.indent(code); + m.appendDeclarationIndent(code); if (m.isAnnotation) { code.append("@annotation "); } @@ -344,9 +437,11 @@ public void indent(StringBuilder sb) { code.append(m.returnType); } code.append("\n"); + m.appendBodyIndent(code); + code.append("skip\n"); - Range range = new Range(new Position(m.line, 0), new Position(m.line, 0)); + Range range = new Range(new Position(m.insertLine0, 0), new Position(m.insertLine0, 0)); TextEdit textEdit = new TextEdit(range, code.toString()); WorkspaceEdit edit = workspaceEdit(m.targetFile.getUriString(), textEdit); CodeAction action = makeQuickFix(title, edit); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java index c1ae97b24..f103215f9 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java @@ -136,6 +136,117 @@ public void codeActionsReturnQuickFixWithWorkspaceEdit() throws IOException { assertTrue(codeActions.stream().anyMatch(a -> a.getEdit() != null)); } + @Test + public void codeActionCanReplaceVarWithLetForConstantLocal() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " va|r value = 1", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("Constant local variables should be defined using 'let'."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Use 'let' for constant local".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected let quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 2); + assertEquals(edit.getRange().getStart().getCharacter(), 4); + assertEquals(edit.getRange().getEnd().getCharacter(), 7); + assertEquals(edit.getNewText(), "let"); + } + + @Test + public void codeActionCanRemoveUnusedImport() throws IOException { + CompletionTestData data = input( + "package test", + "import Wur|st", + "init", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("The import Wurst is never used"); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Remove unused import Wurst".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected import removal quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 1); + assertEquals(edit.getRange().getStart().getCharacter(), 0); + assertEquals(edit.getRange().getEnd().getLine(), 2); + assertEquals(edit.getRange().getEnd().getCharacter(), 0); + assertEquals(edit.getNewText(), ""); + } + + @Test + public void createFunctionQuickfixInClassInsertsUsableMethod() throws IOException { + CompletionTestData data = input( + "package test", + "class C", + " function run()", + " mis|sing(1)", + "init", + " new C().run()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("Could not find function missing."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Create function missing".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected create-function quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 2); + assertTrue(edit.getNewText().contains("\n function missing("), "inserted = " + edit.getNewText()); + assertTrue(edit.getNewText().contains("\n skip\n"), "inserted = " + edit.getNewText()); + } + @Test public void semanticTokensAreProduced() throws IOException { CompletionTestData data = input( From 7d9e30d88ec06547a7f97aadfb48052bea419030 Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 5 Mar 2026 18:07:48 +0100 Subject: [PATCH 2/2] more quickfixes --- de.peeeq.wurstscript/build.gradle | 1 + .../peeeq/wurstio/languageserver/Convert.java | 4 +- .../requests/CodeActionRequest.java | 133 ++++++++++++++++-- .../tests/LspNativeFeaturesTests.java | 107 ++++++++++++++ 4 files changed, 231 insertions(+), 14 deletions(-) diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index e5e14175c..ff96d0c5d 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -101,6 +101,7 @@ dependencies { implementation 'com.google.guava:guava:32.1.3-jre' implementation 'io.vavr:vavr:0.10.7' implementation 'org.eclipse.lsp4j:org.eclipse.lsp4j:0.24.0' + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.17.2' implementation 'org.eclipse.jdt:org.eclipse.jdt.annotation:2.1.0' implementation 'com.google.code.gson:gson:2.10.1' implementation 'commons-lang:commons-lang:2.6' diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java index be11f445d..36014ecc4 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/Convert.java @@ -77,8 +77,8 @@ public static PublishDiagnosticsParams createDiagnostics(String extra, WFile fil break; } - Diagnostic diagnostic = new Diagnostic(range, message, severity, "Wurst"); - diagnostic.setCode("WURST_" + err.getErrorType().name()); + String source = severity == DiagnosticSeverity.Warning ? "Wurst warning" : "Wurst error"; + Diagnostic diagnostic = new Diagnostic(range, message, severity, source); String messageLower = message.toLowerCase(); if (messageLower.contains("deprecated")) { diagnostic.setTags(Collections.singletonList(DiagnosticTag.Deprecated)); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java index 559741246..09a8ab4d9 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java @@ -38,6 +38,8 @@ import java.util.Optional; import java.util.Set; import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -46,6 +48,9 @@ public class CodeActionRequest extends UserRequest>> { private static final String CONSTANT_LOCAL_LET_WARNING = "Constant local variables should be defined using 'let'."; private static final String UNUSED_IMPORT_WARNING_SUFFIX = " is never used"; + private static final String REDUNDANT_IMPORT_MIDDLE = " can be removed, because it is already included in "; + private static final String MIXED_INDENT_WARNING = "Mixing tabs and spaces for indentation."; + private static final Pattern NEVER_READ_NAME = Pattern.compile("^The .*?\\b([A-Za-z_][A-Za-z0-9_]*) is never read\\."); private final WFile filename; private final String buffer; private final int line; @@ -79,13 +84,12 @@ public List> execute(ModelManager modelManager) { WLogger.info("Code action on element " + Utils.printElementWithSource(e)); + List> actions = new ArrayList<>(); + actions.addAll(makeWarningQuickFixes(e)); if (!e.isPresent()) { // TODO non simple TypeRef - - return Collections.emptyList(); + return actions; } - List> actions = new ArrayList<>(); - actions.addAll(makeWarningQuickFixes(e.get())); if (e.get() instanceof ExprNewObject) { ExprNewObject enew = (ExprNewObject) e.get(); @@ -130,11 +134,11 @@ public List> execute(ModelManager modelManager) { return actions; } - private List> makeWarningQuickFixes(Element e) { + private List> makeWarningQuickFixes(Optional e) { List> result = new ArrayList<>(); - if (hasDiagnosticMessage(msg -> msg.contains(CONSTANT_LOCAL_LET_WARNING))) { - findNearest(e, LocalVarDef.class).ifPresent(localVar -> { + if (e.isPresent() && hasDiagnosticMessage(msg -> msg.contains(CONSTANT_LOCAL_LET_WARNING))) { + findNearest(e.get(), LocalVarDef.class).ifPresent(localVar -> { int line0 = localVar.attrSource().getLine() - 1; int startChar = Math.max(0, localVar.attrSource().getStartColumn() - 1); int varPos = findKeywordInLine(line0, startChar, "var"); @@ -152,19 +156,124 @@ private List> makeWarningQuickFixes(Element e) { } if (hasDiagnosticMessage(msg -> msg.startsWith("The import ") && msg.endsWith(UNUSED_IMPORT_WARNING_SUFFIX))) { - findNearest(e, WImport.class).ifPresent(imp -> { - int line0 = imp.attrSource().getLine() - 1; - TextEdit edit = new TextEdit(lineDeletionRange(line0), ""); + int line0 = e.flatMap(elem -> findNearest(elem, WImport.class)) + .map(imp -> imp.attrSource().getLine() - 1) + .orElseGet(() -> diagnostics.get(0).getRange().getStart().getLine()); + String importName = e.flatMap(elem -> findNearest(elem, WImport.class)) + .map(WImport::getPackagename) + .orElse("import"); + TextEdit edit = new TextEdit(lineDeletionRange(line0), ""); + result.add(Either.forRight(makeQuickFix( + "Remove unused import " + importName, + workspaceEdit(filename.getUriString(), edit) + ))); + } + + if (hasDiagnosticMessage(msg -> msg.startsWith("The import ") && msg.contains(REDUNDANT_IMPORT_MIDDLE))) { + int line0 = e.flatMap(elem -> findNearest(elem, WImport.class)) + .map(imp -> imp.attrSource().getLine() - 1) + .orElseGet(() -> diagnostics.get(0).getRange().getStart().getLine()); + String importName = firstDiagnosticMessage(msg -> msg.startsWith("The import ") && msg.contains(REDUNDANT_IMPORT_MIDDLE)) + .map(msg -> { + int start = "The import ".length(); + int end = msg.indexOf(REDUNDANT_IMPORT_MIDDLE); + return end > start ? msg.substring(start, end) : "import"; + }) + .orElse("import"); + TextEdit edit = new TextEdit(lineDeletionRange(line0), ""); + result.add(Either.forRight(makeQuickFix( + "Remove redundant import " + importName, + workspaceEdit(filename.getUriString(), edit) + ))); + } + + firstDiagnosticMessage(msg -> msg.startsWith("The ") && msg.contains(" is never read. If intentional, prefix with \"_\"")) + .ifPresent(msg -> { + Matcher m = NEVER_READ_NAME.matcher(msg); + if (!m.find()) { + return; + } + String name = m.group(1); + Diagnostic d = firstDiagnostic(msg2 -> msg.equals(msg2)); + if (d == null) { + return; + } + int line0 = d.getRange().getStart().getLine(); + int col0 = d.getRange().getStart().getCharacter(); + String lineText = getLine(line0); + if (lineText == null) { + return; + } + if (col0 < lineText.length() && lineText.charAt(col0) == '_') { + return; + } + TextEdit edit = new TextEdit(new Range(new Position(line0, col0), new Position(line0, col0)), "_"); result.add(Either.forRight(makeQuickFix( - "Remove unused import " + imp.getPackagename(), + "Prefix '" + name + "' with '_'", workspaceEdit(filename.getUriString(), edit) ))); }); - } + + firstDiagnosticMessage(msg -> msg.contains(MIXED_INDENT_WARNING)).ifPresent(msg -> { + Diagnostic d = firstDiagnostic(msg2 -> msg2.contains(MIXED_INDENT_WARNING)); + if (d == null) { + return; + } + int line0 = d.getRange().getStart().getLine(); + String lineText = getLine(line0); + if (lineText == null) { + return; + } + int end = 0; + while (end < lineText.length()) { + char c = lineText.charAt(end); + if (c == ' ' || c == '\t') { + end++; + } else { + break; + } + } + if (end == 0) { + return; + } + String indent = lineText.substring(0, end); + String normalized = indent.replace("\t", " "); + if (indent.equals(normalized)) { + return; + } + TextEdit edit = new TextEdit(new Range(new Position(line0, 0), new Position(line0, end)), normalized); + result.add(Either.forRight(makeQuickFix( + "Normalize indentation on this line", + workspaceEdit(filename.getUriString(), edit) + ))); + }); return result; } + private Optional firstDiagnosticMessage(Predicate matcher) { + return diagnostics.stream() + .map(Diagnostic::getMessage) + .filter(msg -> msg != null && !msg.isEmpty()) + .filter(matcher) + .findFirst(); + } + + private Diagnostic firstDiagnostic(Predicate matcher) { + return diagnostics.stream() + .filter(d -> d.getMessage() != null && matcher.test(d.getMessage())) + .findFirst() + .orElse(null); + } + + private String getLine(int line0) { + String[] lines = buffer.split("\\r?\\n", -1); + if (line0 < 0 || line0 >= lines.length) { + return null; + } + return lines[line0]; + } + private boolean hasDiagnosticMessage(Predicate matcher) { return diagnostics.stream() .map(Diagnostic::getMessage) diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java index f103215f9..e857a1e46 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java @@ -209,6 +209,113 @@ public void codeActionCanRemoveUnusedImport() throws IOException { assertEquals(edit.getNewText(), ""); } + @Test + public void codeActionCanRemoveRedundantImport() throws IOException { + CompletionTestData data = input( + "package test", + "import BuildingCon|stants", + "init", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("The import BuildingConstants can be removed, because it is already included in CFBuilding."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Remove redundant import BuildingConstants".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected redundant import quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 1); + assertEquals(edit.getRange().getEnd().getLine(), 2); + assertEquals(edit.getNewText(), ""); + } + + @Test + public void codeActionCanPrefixUnreadVariableWithUnderscore() throws IOException { + CompletionTestData data = input( + "package test", + "init", + " int hu|P = 1", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("The variable huP is never read. If intentional, prefix with \"_\" to suppress this warning."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Prefix 'huP' with '_'".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected underscore quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 2); + assertEquals(edit.getRange().getStart().getCharacter(), data.column); + assertEquals(edit.getRange().getEnd().getCharacter(), data.column); + assertEquals(edit.getNewText(), "_"); + } + + @Test + public void codeActionCanNormalizeMixedIndentation() throws IOException { + CompletionTestData data = input( + "package test", + "init", + "\t sk|ip", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("Mixing tabs and spaces for indentation."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + + CodeAction fix = codeActions.stream() + .filter(a -> "Normalize indentation on this line".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected indentation quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + assertEquals(edit.getRange().getStart().getLine(), 2); + assertEquals(edit.getRange().getStart().getCharacter(), 0); + assertEquals(edit.getRange().getEnd().getCharacter(), 3); + assertEquals(edit.getNewText(), " "); + } + @Test public void createFunctionQuickfixInClassInsertsUsableMethod() throws IOException { CompletionTestData data = input(