diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/value/ValueCompletionProcessor.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/value/ValueCompletionProcessor.java index 8a7863a6e..690d3af3d 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/value/ValueCompletionProcessor.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/value/ValueCompletionProcessor.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2017, 2019 Pivotal, Inc. + * Copyright (c) 2017, 2024 Pivotal, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -22,13 +22,18 @@ import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.ITypeBinding; import org.eclipse.jdt.core.dom.MemberValuePair; +import org.eclipse.jdt.core.dom.QualifiedName; import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.StringLiteral; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ide.vscode.boot.java.beans.QualifierCompletionProposal; import org.springframework.ide.vscode.boot.java.handlers.CompletionProvider; import org.springframework.ide.vscode.boot.metadata.ProjectBasedPropertyIndexProvider; import org.springframework.ide.vscode.boot.metadata.PropertyInfo; import org.springframework.ide.vscode.boot.metadata.SpringPropertyIndexProvider; +import org.springframework.ide.vscode.commons.java.IClasspathUtil; import org.springframework.ide.vscode.commons.java.IJavaProject; import org.springframework.ide.vscode.commons.languageserver.completion.DocumentEdits; import org.springframework.ide.vscode.commons.languageserver.completion.ICompletionProposal; @@ -43,6 +48,8 @@ import org.springframework.ide.vscode.commons.util.text.TextDocument; * @author Martin Lippert */ public class ValueCompletionProcessor implements CompletionProvider { + + private static final Logger log = LoggerFactory.getLogger(ValueCompletionProcessor.class); private final SpringPropertyIndexProvider indexProvider; private final ProjectBasedPropertyIndexProvider adHocIndexProvider; @@ -59,6 +66,13 @@ public class ValueCompletionProcessor implements CompletionProvider { int offset, TextDocument doc, Collection completions) { try { + Optional optionalProject = this.projectFinder.find(doc.getId()); + if (optionalProject.isEmpty()) { + return; + } + + IJavaProject project = optionalProject.get(); + // case: @Value(<*>) if (node == annotation && doc.get(offset - 1, 2).endsWith("()")) { List> matches = findMatches("", doc); @@ -75,37 +89,70 @@ public class ValueCompletionProcessor implements CompletionProvider { completions.add(proposal); } + + addClasspathResourceProposals(project, doc, offset, offset, "", true, completions); } // case: @Value(prefix<*>) else if (node instanceof SimpleName && node.getParent() instanceof Annotation) { - computeProposalsForSimpleName(node, completions, offset, doc); + computeProposalsForSimpleName(project, node, completions, offset, doc); + } + // case: @Value(file.ext<*>) - the "." causes a QualifierNode to be generated + else if (node instanceof SimpleName && node.getParent() instanceof QualifiedName && node.getParent().getParent() instanceof Annotation) { + computeProposalsForSimpleName(project, node.getParent(), completions, offset, doc); } // case: @Value(value=<*>) else if (node instanceof SimpleName && node.getParent() instanceof MemberValuePair && "value".equals(((MemberValuePair)node.getParent()).getName().toString())) { - computeProposalsForSimpleName(node, completions, offset, doc); + computeProposalsForSimpleName(project, node, completions, offset, doc); + } + // case: @Value(value=<*>) + else if (node instanceof SimpleName && node.getParent() instanceof QualifiedName && node.getParent().getParent() instanceof MemberValuePair + && "value".equals(((MemberValuePair)node.getParent().getParent()).getName().toString())) { + computeProposalsForSimpleName(project, node.getParent(), completions, offset, doc); } // case: @Value("prefix<*>") else if (node instanceof StringLiteral && node.getParent() instanceof Annotation) { if (node.toString().startsWith("\"") && node.toString().endsWith("\"")) { - computeProposalsForStringLiteral(node, completions, offset, doc); + computeProposalsForStringLiteral(project, (StringLiteral) node, completions, offset, doc); } } // case: @Value(value="prefix<*>") else if (node instanceof StringLiteral && node.getParent() instanceof MemberValuePair && "value".equals(((MemberValuePair)node.getParent()).getName().toString())) { if (node.toString().startsWith("\"") && node.toString().endsWith("\"")) { - computeProposalsForStringLiteral(node, completions, offset, doc); + computeProposalsForStringLiteral(project, (StringLiteral) node, completions, offset, doc); } } } catch (Exception e) { - e.printStackTrace(); + log.error("problem while looking for value annotation proposals", e); } } - private void computeProposalsForSimpleName(ASTNode node, Collection completions, int offset, - IDocument doc) { + private void addClasspathResourceProposals(IJavaProject project, TextDocument doc, int startOffset, int endOffset, String prefix, boolean includeQuotes, Collection completions) { + String[] resources = findResources(project, prefix); + + double score = resources.length + 1000; + for (String resource : resources) { + + DocumentEdits edits = new DocumentEdits(doc, false); + + if (includeQuotes) { + edits.replace(startOffset, endOffset, "\"classpath:" + resource + "\""); + } + else { + edits.replace(startOffset, endOffset, "classpath:" + resource); + } + + String label = "classpath:" + resource; + + ICompletionProposal proposal = new QualifierCompletionProposal(edits, label, label, null, score--); + completions.add(proposal); + } + + } + + private void computeProposalsForSimpleName(IJavaProject project, ASTNode node, Collection completions, int offset, TextDocument doc) { String prefix = identifyPropertyPrefix(node.toString(), offset - node.getStartPosition()); int startOffset = node.getStartPosition(); @@ -125,10 +172,12 @@ public class ValueCompletionProcessor implements CompletionProvider { completions.add(proposal); } + + String unfilteredPrefix = node.toString().substring(0, offset - node.getStartPosition()); + addClasspathResourceProposals(project, doc, startOffset, endOffset, unfilteredPrefix, true, completions); } - private void computeProposalsForStringLiteral(ASTNode node, Collection completions, int offset, - IDocument doc) throws BadLocationException { + private void computeProposalsForStringLiteral(IJavaProject project, StringLiteral node, Collection completions, int offset, TextDocument doc) throws BadLocationException { String prefix = identifyPropertyPrefix(doc.get(node.getStartPosition() + 1, offset - (node.getStartPosition() + 1)), offset - (node.getStartPosition() + 1)); int startOffset = offset - prefix.length(); @@ -164,6 +213,9 @@ public class ValueCompletionProcessor implements CompletionProvider { completions.add(proposal); } + + String unfilteredPrefix = node.getLiteralValue().substring(0, offset - (node.getStartPosition() + 1)); + addClasspathResourceProposals(project, doc, startOffset, endOffset, unfilteredPrefix, false, completions); } private boolean isClosingBracketMissing(String fullNodeContent) { @@ -220,4 +272,14 @@ public class ValueCompletionProcessor implements CompletionProvider { return matches; } + private String[] findResources(IJavaProject project, String prefix) { + String[] resources = IClasspathUtil.getClasspathResources(project.getClasspath()).stream() + .distinct() + .map(r -> r.replaceAll("\\\\", "/")) + .filter(r -> ("classpath:" + r).contains(prefix)) + .toArray(String[]::new); + + return resources; + } + } diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/ValueCompletionTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/ValueCompletionTest.java index 580e380da..8343be975 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/ValueCompletionTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/java/value/test/ValueCompletionTest.java @@ -153,10 +153,15 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${data.prop2}\"<*>)", "@Value(\"${else.prop3}\"<*>)", "@Value(\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md\"<*>)", + "@Value(\"classpath:org/random-resource-org.md\"<*>)", + "@Value(\"classpath:org/test/random-resource-org-test.txt\"<*>)"); } @Test @@ -164,17 +169,23 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(value=<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(value=\"${data.prop2}\"<*>)", "@Value(value=\"${else.prop3}\"<*>)", "@Value(value=\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions( + "@Value(value=\"classpath:random-resource-root.md\"<*>)", + "@Value(value=\"classpath:org/random-resource-org.md\"<*>)", + "@Value(value=\"classpath:org/test/random-resource-org-test.txt\"<*>)"); } @Test void testEmptyBracketsCompletionWithWrongParamName() throws Exception { prepareCase("@Value(\"onField\")", "@Value(another=<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions(); + assertPropertyCompletions(); + assertClasspathCompletions(); } @Test @@ -182,10 +193,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value($<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${data.prop2}\"<*>)", "@Value(\"${else.prop3}\"<*>)", "@Value(\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); } @Test @@ -193,10 +206,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(value=$<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(value=\"${data.prop2}\"<*>)", "@Value(value=\"${else.prop3}\"<*>)", "@Value(value=\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); } @Test @@ -204,10 +219,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"$<*>\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${data.prop2}<*>\")", "@Value(\"${else.prop3}<*>\")", "@Value(\"${spring.prop1}<*>\")"); + + assertClasspathCompletions(); } @Test @@ -215,10 +232,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(value=\"$<*>\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(value=\"${data.prop2}<*>\")", "@Value(value=\"${else.prop3}<*>\")", "@Value(value=\"${spring.prop1}<*>\")"); + + assertClasspathCompletions(); } @Test @@ -226,10 +245,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"${<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${data.prop2<*>}\")", "@Value(\"${else.prop3<*>}\")", "@Value(\"${spring.prop1<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -237,10 +258,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(value=\"${<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(value=\"${data.prop2<*>}\")", "@Value(value=\"${else.prop3<*>}\")", "@Value(value=\"${spring.prop1<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -248,10 +271,15 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"<*>\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${data.prop2}<*>\")", "@Value(\"${else.prop3}<*>\")", "@Value(\"${spring.prop1}<*>\")"); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md<*>\")", + "@Value(\"classpath:org/random-resource-org.md<*>\")", + "@Value(\"classpath:org/test/random-resource-org-test.txt<*>\")"); } @Test @@ -259,8 +287,124 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(spri<*>)"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); + } + + @Test + void testComplexPrefixCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(spring.pr<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions( + "@Value(\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); + } + + @Test + void testPrefixCompletionWithParamName() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(value=sprin<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions( + "@Value(value=\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); + } + + @Test + void testComplexPrefixCompletionWithParamName() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(value=spring.pr<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions( + "@Value(value=\"${spring.prop1}\"<*>)"); + + assertClasspathCompletions(); + } + + @Test + void testClasspathPrefixCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(cla<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md\"<*>)", + "@Value(\"classpath:org/random-resource-org.md\"<*>)", + "@Value(\"classpath:org/test/random-resource-org-test.txt\"<*>)"); + } + + @Test + void testResourceNameInPrefixCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(root<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md\"<*>)"); + } + + @Test + void testComplexResourceNameInPrefixCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(root.md<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md\"<*>)"); + } + + @Test + void testComplexResourceNameInPrefixWithinQoutesCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(\"root.md<*>\")"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md<*>\")"); + } + + @Test + void testComplexResourceNameInPrefixWithParamNameCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(value=root.md<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(value=\"classpath:random-resource-root.md\"<*>)"); + } + + @Test + void testComplexResourceNameInPrefixWithinQoutesAndParamNameCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(value=\"root.md<*>\")"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(value=\"classpath:random-resource-root.md<*>\")"); + } + + @Test + void testClasspathPrefixCompletionWithParamName() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(value=cla<*>)"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(value=\"classpath:random-resource-root.md\"<*>)", + "@Value(value=\"classpath:org/random-resource-org.md\"<*>)", + "@Value(value=\"classpath:org/test/random-resource-org-test.txt\"<*>)"); } @Test @@ -268,8 +412,34 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"spri<*>\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"${spring.prop1}<*>\")"); + + assertClasspathCompletions(); + } + + @Test + void testComplexPrefixCompletionWithQuotes() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(\"spring.pr<*>\")"); + prepareDefaultIndexData(); + + assertPropertyCompletions( + "@Value(\"${spring.prop1}<*>\")"); + + assertClasspathCompletions(); + } + + @Test + void testQuotedClasspathPrefixCompletion() throws Exception { + prepareCase("@Value(\"onField\")", "@Value(\"cla<*>\")"); + prepareDefaultIndexData(); + + assertPropertyCompletions(); + + assertClasspathCompletions( + "@Value(\"classpath:random-resource-root.md<*>\")", + "@Value(\"classpath:org/random-resource-org.md<*>\")", + "@Value(\"classpath:org/test/random-resource-org-test.txt<*>\")"); } @Test @@ -277,10 +447,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{${data.prop2}<*>}\")", "@Value(\"#{${else.prop3}<*>}\")", "@Value(\"#{${spring.prop1}<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -288,10 +460,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{345$<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{345${data.prop2}<*>}\")", "@Value(\"#{345${else.prop3}<*>}\")", "@Value(\"#{345${spring.prop1}<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -299,10 +473,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{345${<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{345${data.prop2}<*>}\")", "@Value(\"#{345${else.prop3}<*>}\")", "@Value(\"#{345${spring.prop1}<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -310,10 +486,12 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{345${<*>}}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{345${data.prop2<*>}}\")", "@Value(\"#{345${else.prop3<*>}}\")", "@Value(\"#{345${spring.prop1<*>}}\")"); + + assertClasspathCompletions(); } @Test @@ -321,8 +499,10 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{345${spri<*>}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{345${spring.prop1}<*>}\")"); + + assertClasspathCompletions(); } @Test @@ -330,8 +510,10 @@ public class ValueCompletionTest { prepareCase("@Value(\"onField\")", "@Value(\"#{345${spri<*>}}\")"); prepareDefaultIndexData(); - assertAnnotationCompletions( + assertPropertyCompletions( "@Value(\"#{345${spring.prop1<*>}}\")"); + + assertClasspathCompletions(); } @Test @@ -353,6 +535,10 @@ public class ValueCompletionTest { editor.assertContextualCompletions( "<*>" , //==> + "classpath:random-resource-root.md<*>", + "classpath:org/random-resource-org.md<*>", + "classpath:org/test/random-resource-org-test.txt<*>", + "${data.prop2}<*>", "${else.prop3}<*>", "${spring.prop1}<*>" @@ -364,7 +550,11 @@ public class ValueCompletionTest { editor.assertContextualCompletions( "<*>" , //==> - "${data.prop2}<*>", + "classpath:random-resource-root.md<*>", + "classpath:org/random-resource-org.md<*>", + "classpath:org/test/random-resource-org-test.txt<*>", + + "${data.prop2}<*>", "${else.prop3}<*>", "${spring.ad-hoc.other-thingy}<*>", "${spring.ad-hoc.thingy}<*>", @@ -394,18 +584,47 @@ public class ValueCompletionTest { editor = new Editor(harness, content, LanguageId.JAVA); } - private void assertAnnotationCompletions(String... completedAnnotations) throws Exception { + private void assertPropertyCompletions(String... completedAnnotations) throws Exception { List completions = editor.getCompletions(); - int i = 0; - for (String expectedCompleted : completedAnnotations) { + + List filteredCompletions = completions.stream() + .filter(item -> !item.getTextEdit().getLeft().getNewText().contains("classpath")) + .toList(); + + assertEquals(completedAnnotations.length, filteredCompletions.size()); + + for (int i = 0; i < completedAnnotations.length; i++) { + CompletionItem completion = filteredCompletions.get(i); + Editor clonedEditor = editor.clone(); - clonedEditor.apply(completions.get(i++)); - if (!clonedEditor.getText().contains(expectedCompleted)) { - fail("Not found '"+expectedCompleted+"' in \n"+clonedEditor.getText()); + clonedEditor.apply(completion); + + String expected = completedAnnotations[i]; + if (!clonedEditor.getText().contains(expected)) { + fail("Not found '" + expected +"' in \n" + clonedEditor.getText()); } } - - assertEquals(i, completions.size()); } + private void assertClasspathCompletions(String... completedAnnotations) throws Exception { + List completions = editor.getCompletions(); + + List filteredCompletions = completions.stream() + .filter(item -> item.getTextEdit().getLeft().getNewText().contains("classpath")) + .toList(); + + assertEquals(completedAnnotations.length, filteredCompletions.size()); + + for (int i = 0; i < completedAnnotations.length; i++) { + CompletionItem completion = filteredCompletions.get(i); + + Editor clonedEditor = editor.clone(); + clonedEditor.apply(completion); + + String expected = completedAnnotations[i]; + if (!clonedEditor.getText().contains(expected)) { + fail("Not found '" + expected +"' in \n" + clonedEditor.getText()); + } + } + } } diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/org/random-resource-org.md b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/org/random-resource-org.md new file mode 100644 index 000000000..e69de29bb diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/org/test/random-resource-org-test.txt b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/org/test/random-resource-org-test.txt new file mode 100644 index 000000000..e69de29bb diff --git a/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/random-resource-root.md b/headless-services/spring-boot-language-server/src/test/resources/test-projects/test-annotations/src/main/java/random-resource-root.md new file mode 100644 index 000000000..e69de29bb