Bug 304219 - Formatting in Java Stack Trace Console should support more
keywords

Apart from unit test for existing formatting it add support for the
following keywords:
 - Caused by:
 - Suppressed:
 - ... n more
 - [CYCLIC REFERENCE:x]

Dependent if a 'Caused by' is the cause for the main or a suppressed
exception its indentation can differ. Java Stack Trace Console format
will not break those specific indentations.

Change-Id: Ida5507354fd30788975fcf88158a57dde4f464d1
Signed-off-by: Paul Pazderski <paul-eclipse@ppazderski.de>
diff --git a/org.eclipse.jdt.debug.tests/console tests/org/eclipse/jdt/debug/tests/console/JavaStackTraceConsoleTest.java b/org.eclipse.jdt.debug.tests/console tests/org/eclipse/jdt/debug/tests/console/JavaStackTraceConsoleTest.java
index d11b319..924dcda 100644
--- a/org.eclipse.jdt.debug.tests/console tests/org/eclipse/jdt/debug/tests/console/JavaStackTraceConsoleTest.java
+++ b/org.eclipse.jdt.debug.tests/console tests/org/eclipse/jdt/debug/tests/console/JavaStackTraceConsoleTest.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2014, 2019 SAP SE and others.
+ * Copyright (c) 2014, 2020 SAP SE and others.
  *
  * This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -12,10 +12,12 @@
  *     SAP SE - initial API and implementation
  *     Paul Pazderski - Bug 546900: Tests to check initial console content and content persistence
  *     Paul Pazderski - Bug 343023: Tests for 'clear initial content on first edit'
+ *     Paul Pazderski - Bug 304219: Tests for formatting
  *******************************************************************************/
 package org.eclipse.jdt.debug.tests.console;
 
 import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotEquals;
 
 import java.io.IOException;
 import java.nio.file.Files;
@@ -24,6 +26,8 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.eclipse.core.runtime.jobs.Job;
 import org.eclipse.jdt.debug.tests.AbstractDebugTest;
@@ -35,6 +39,7 @@
 import org.eclipse.jface.text.BadLocationException;
 import org.eclipse.jface.text.BadPositionCategoryException;
 import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IRegion;
 import org.eclipse.jface.text.Position;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.ST;
@@ -57,6 +62,9 @@
  */
 public class JavaStackTraceConsoleTest extends AbstractDebugTest {
 
+	private final static Pattern LEFT_INDENT = Pattern.compile("^[ \\t]*");
+	private final static Pattern RIGHT_INDENT = Pattern.compile("\\s+$");
+
 	private final JavaStackTraceConsoleFactory fConsoleFactory = new JavaStackTraceConsoleFactory();
 	private JavaStackTraceConsole fConsole;
 
@@ -269,6 +277,223 @@
 		});
 	}
 
+	/** Test formatting of a plain simple stack trace. */
+	public void testFormatSimple() {
+		IDocument doc = consoleDocumentFormatted("java.lang.AssertionError: expected:5 but was:7\n\n"
+				+ "at org.junit.Ass\nert.fail(Assert.java:88) \n" + "at\norg.junit.   \nAssert.failNotEquals(Assert.java:834)\n"
+				+ "at org.junit.Assert.assertEquals(Assert.java:118)\n" + "at \norg.junit.Assert.assertEquals\n(Assert.java:144)");
+		assertEquals("java.lang.AssertionError: expected:5 but was:7", getLine(doc, 0));
+		assertEquals("at org.junit.Assert.fail(Assert.java:88)", getLine(doc, 1).trim());
+		assertEquals("at org.junit.Assert.failNotEquals(Assert.java:834)", getLine(doc, 2).trim());
+		assertEquals("at org.junit.Assert.assertEquals(Assert.java:118)", getLine(doc, 3).trim());
+		assertEquals("at org.junit.Assert.assertEquals(Assert.java:144)", getLine(doc, 4).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting of a stack trace including thread name. */
+	public void testFormatThreadName() {
+		IDocument doc = consoleDocumentFormatted("Exception in thread \"ma\nin\" java.lang.NullPointerException\n"
+				+ "at \nStacktrace.main(Stacktrace.java:4)");
+		assertEquals("Exception in thread \"main\" java.lang.NullPointerException", getLine(doc, 0));
+		assertEquals("at Stacktrace.main(Stacktrace.java:4)", getLine(doc, 1).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting with some less common method names. */
+	public void testFormatUncommonMethods() {
+		IDocument doc = consoleDocumentFormatted("Stack Trace\n" + "  at org.eclipse.core.runtime.SafeRunner.run\n(SafeRunner.java:43)\n"
+				+ "      at org.eclipse.ui.internal.JFaceUtil.lambda$0(JFaceUtil.java:47)\n"
+				+ "    at org.eclipse.ui.internal.JFaceUtil$$Lambda$107/0x00000   \n008013c5c40.run(Unknown Source)\n"
+				+ "\tat org.eclipse.jface.util.SafeRunnable.run(SafeRunnable.java:174)\n"
+				+ "         at java.base@12/java.lang.reflect.Method.invoke(Method.java:567)\n"
+				+ " \n at app/\n/org.eclipse.equinox.launcher.Main.main(Main.java:1438)");
+		assertEquals("Stack Trace", getLine(doc, 0));
+		assertEquals("at org.eclipse.core.runtime.SafeRunner.run(SafeRunner.java:43)", getLine(doc, 1).trim());
+		assertEquals("at org.eclipse.ui.internal.JFaceUtil.lambda$0(JFaceUtil.java:47)", getLine(doc, 2).trim());
+		assertEquals("at org.eclipse.ui.internal.JFaceUtil$$Lambda$107/0x00000008013c5c40.run(Unknown Source)", getLine(doc, 3).trim());
+		assertEquals("at org.eclipse.jface.util.SafeRunnable.run(SafeRunnable.java:174)", getLine(doc, 4).trim());
+		assertEquals("at java.base@12/java.lang.reflect.Method.invoke(Method.java:567)", getLine(doc, 5).trim());
+		assertEquals("at app//org.eclipse.equinox.launcher.Main.main(Main.java:1438)", getLine(doc, 6).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting with a 'locked' entry. */
+	public void testFormatLocked() {
+		IDocument doc = consoleDocumentFormatted("java.lang.Thread.State: RUNNABLE\n"
+				+ " at java.net.PlainSocketImpl.socketAccept(Native Method)\n\n\n"
+				+ "at java.net.PlainSocketImpl\n.accept(PlainSocketImpl.java:408)\n" + "\t - locked <0x911d3c30>   (a java.net.SocksSocketImpl)\n"
+				+ "    at java.net.ServerSocket.implAccept(ServerSocket.java:462)\n" + "at java.net.ServerSocket.accept(ServerSocket.java:430)");
+		assertEquals("java.lang.Thread.State: RUNNABLE", getLine(doc, 0));
+		assertEquals("at java.net.PlainSocketImpl.socketAccept(Native Method)", getLine(doc, 1).trim());
+		assertEquals("at java.net.PlainSocketImpl.accept(PlainSocketImpl.java:408)", getLine(doc, 2).trim());
+		assertEquals("- locked <0x911d3c30> (a java.net.SocksSocketImpl)", getLine(doc, 3).trim());
+		assertEquals("at java.net.ServerSocket.implAccept(ServerSocket.java:462)", getLine(doc, 4).trim());
+		assertEquals("at java.net.ServerSocket.accept(ServerSocket.java:430)", getLine(doc, 5).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting with a ... more entry. */
+	public void testFormatMore() {
+		// additional this one is missing the 'header' line and starting with an 'at' line
+		IDocument doc = consoleDocumentFormatted(" at org.springframework.orm.jpa.persistenceunit.DefaultPersistenceUnitManager.preparePersistenceUnitInfos(DefaultPersistenceUnitManager.java:470)\n"
+				+ "    at org.springframework.orm.jpa.persistenceunit.DefaultPersistenceUnitManager.afterPropertiesSet(DefaultPersistenceUnitManager.java:424)\n"
+				+ "at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:310)\n"
+				+ "          at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:318)\n\n"
+				+ "  \t\t    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1633)\n"
+				+ "   \t \tat org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1570)\n"
+				+ "  ... 53 more\n" + "");
+		assertEquals("at org.springframework.orm.jpa.persistenceunit.DefaultPersistenceUnitManager.preparePersistenceUnitInfos(DefaultPersistenceUnitManager.java:470)", getLine(doc, 1).trim());
+		assertEquals("at org.springframework.orm.jpa.persistenceunit.DefaultPersistenceUnitManager.afterPropertiesSet(DefaultPersistenceUnitManager.java:424)", getLine(doc, 2).trim());
+		assertEquals("at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:310)", getLine(doc, 3).trim());
+		assertEquals("at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:318)", getLine(doc, 4).trim());
+		assertEquals("at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1633)", getLine(doc, 5).trim());
+		assertEquals("at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1570)", getLine(doc, 6).trim());
+		assertEquals("... 53 more", getLine(doc, 7).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting stack trace with cause. */
+	public void testFormatCause() {
+		IDocument doc = consoleDocumentFormatted("HighLevelException:\n LowLevelException\n" + "\tat Junk.a(Junk.java:13)\n"
+				+ "         at Junk.main(Junk.java:4)\n" + "     Caused by: LowLevelException\n" + " at Junk.e(Junk.java:30)\n"
+				+ "    at Junk.d\n(Junk.java:27)\n" + "at Junk.c(Junk.java:21)");
+		assertEquals("HighLevelException: LowLevelException", getLine(doc, 0));
+		assertEquals("at Junk.a(Junk.java:13)", getLine(doc, 1).trim());
+		assertEquals("at Junk.main(Junk.java:4)", getLine(doc, 2).trim());
+		assertEquals("Caused by: LowLevelException", getLine(doc, 3));
+		assertEquals("at Junk.e(Junk.java:30)", getLine(doc, 4).trim());
+		assertEquals("at Junk.d(Junk.java:27)", getLine(doc, 5).trim());
+		assertEquals("at Junk.c(Junk.java:21)", getLine(doc, 6).trim());
+		checkIndentationConsistency(doc, 0);
+
+		// nested causes
+		doc = consoleDocumentFormatted("HighLevelException:\t MidLevelException:\n LowLevelException\n" + "\tat Junk.a(Junk.java:13)\n"
+				+ "    at Junk.main(Junk.java:4)\n" + " Caused by: MidLevelException: LowLevelException\n" + "    at Junk.c(Junk.java:23)\n"
+				+ "      at Junk.b(Junk.java:17)\n" + "      at Junk.a(Junk.java:11)\n" + "... 1 more\n" + " Caused by: LowLevelException\n"
+				+ "     at Junk.e(Junk.java:30)\n" + "at Junk.d(Junk.java:27)\n" + "   at Junk.c(Junk.java:21)\n" + "         ... 3 more\n");
+		assertEquals("HighLevelException: MidLevelException: LowLevelException", getLine(doc, 0));
+		assertEquals("at Junk.a(Junk.java:13)", getLine(doc, 1).trim());
+		assertEquals("at Junk.main(Junk.java:4)", getLine(doc, 2).trim());
+		assertEquals("Caused by: MidLevelException: LowLevelException", getLine(doc, 3));
+		assertEquals("at Junk.c(Junk.java:23)", getLine(doc, 4).trim());
+		assertEquals("at Junk.b(Junk.java:17)", getLine(doc, 5).trim());
+		assertEquals("at Junk.a(Junk.java:11)", getLine(doc, 6).trim());
+		assertEquals("... 1 more", getLine(doc, 7).trim());
+		assertEquals("Caused by: LowLevelException", getLine(doc, 8));
+		assertEquals("at Junk.e(Junk.java:30)", getLine(doc, 9).trim());
+		assertEquals("at Junk.d(Junk.java:27)", getLine(doc, 10).trim());
+		assertEquals("at Junk.c(Junk.java:21)", getLine(doc, 11).trim());
+		assertEquals("... 3 more", getLine(doc, 12).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting stack trace with suppressed exceptions. */
+	public void testFormatSuppressed() {
+		IDocument doc = consoleDocumentFormatted("Exception in thread \"main\" java.lang.Exception: Something happened\n" + "at Foo.bar(Native)\n"
+				+ "  at Foo.main(Foo.java:5)\n" + "  Suppressed: Resource$CloseFailException: Resource ID = 0\n"
+				+ "    at Resource.close(Resource\n.java:26)\n" + "      at Foo.bar(Foo.java)\n" + "         ... 1 more\n" + "");
+		assertEquals("Exception in thread \"main\" java.lang.Exception: Something happened", getLine(doc, 0));
+		assertEquals("at Foo.bar(Native)", getLine(doc, 1).trim());
+		assertEquals("at Foo.main(Foo.java:5)", getLine(doc, 2).trim());
+		assertEquals("Suppressed: Resource$CloseFailException: Resource ID = 0", getLine(doc, 3).trim());
+		assertEquals("at Resource.close(Resource.java:26)", getLine(doc, 4).trim());
+		assertEquals("at Foo.bar(Foo.java)", getLine(doc, 5).trim());
+		assertEquals("... 1 more", getLine(doc, 6).trim());
+		checkIndentationConsistency(doc, 0);
+
+		// multiple suppressed
+		doc = consoleDocumentFormatted("Exception in thread \"main\" java.lang.Exception: Main block\n" + "  at Foo3.main(Foo3.java:7)\n"
+				+ "     Suppressed: Resource$CloseFailException: Resource ID = 2\n" + "      at Resource.close(Resource.java:26)\n"
+				+ "   \t\tat Foo3.main(Foo3.java:5)\n" + "Suppressed: Resource$CloseFailException: Resource ID = 1\n"
+				+ "      at Resource.close(Resource.java:26)\n" + "                at Foo3.main(Foo3.java:5)\n" + "");
+		assertEquals("Exception in thread \"main\" java.lang.Exception: Main block", getLine(doc, 0));
+		assertEquals("at Foo3.main(Foo3.java:7)", getLine(doc, 1).trim());
+		assertEquals("Suppressed: Resource$CloseFailException: Resource ID = 2", getLine(doc, 2).trim());
+		assertEquals("at Resource.close(Resource.java:26)", getLine(doc, 3).trim());
+		assertEquals("at Foo3.main(Foo3.java:5)", getLine(doc, 4).trim());
+		assertEquals("Suppressed: Resource$CloseFailException: Resource ID = 1", getLine(doc, 5).trim());
+		assertEquals("at Resource.close(Resource.java:26)", getLine(doc, 6).trim());
+		assertEquals("at Foo3.main(Foo3.java:5)", getLine(doc, 7).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting stack trace with mixture of cause and suppressed. */
+	public void testFormatSuppressedWithCause() {
+		// exception with suppressed and cause
+		IDocument doc = consoleDocumentFormatted("Exception in thread \"main\" java.lang.Exception: Main block\n" + "  at Foo3.main(Foo3.java:7)\n"
+				+ "  Suppressed: Resource$CloseFailException: Resource ID = 1\n" + "          at Resource.close(Resource.java:26)\n"
+				+ "          at Foo3.main(Foo3.java:5)\n" + "Caused by: java.lang.Exception: I did it\n" + "  at Foo3.main(Foo3.java:8)\n");
+		assertEquals("Exception in thread \"main\" java.lang.Exception: Main block", getLine(doc, 0));
+		assertEquals("at Foo3.main(Foo3.java:7)", getLine(doc, 1).trim());
+		assertEquals("Suppressed: Resource$CloseFailException: Resource ID = 1", getLine(doc, 2).trim());
+		assertEquals("at Resource.close(Resource.java:26)", getLine(doc, 3).trim());
+		assertEquals("at Foo3.main(Foo3.java:5)", getLine(doc, 4).trim());
+		assertEquals("Caused by: java.lang.Exception: I did it", getLine(doc, 5).trim());
+		assertEquals("at Foo3.main(Foo3.java:8)", getLine(doc, 6).trim());
+		checkIndentationConsistency(doc, 0);
+		// Additional indentation check. Since cause is linked to primary exception it must be less indented as suppressed stuff.
+		assertEquals(getLineIndentation(getLine(doc, 0)), getLineIndentation(getLine(doc, 5)));
+		assertEquals(getLineIndentation(getLine(doc, 1)), getLineIndentation(getLine(doc, 6)));
+		assertTrue(getLineIndentation(getLine(doc, 3)) > getLineIndentation(getLine(doc, 5)));
+		assertTrue(getLineIndentation(getLine(doc, 3)) > getLineIndentation(getLine(doc, 6)));
+
+		// exception with suppressed and cause for the suppressed
+		doc = consoleDocumentFormatted("Exception in thread \"main\" java.lang.Exception: Main block\n" + "  at Foo4.main(Foo4.java:6)\n"
+				+ "  Suppressed: Resource2$CloseFailException: Resource ID = 1\n" + "          at Resource2.close(Resource2.java:20)\n"
+				+ "          at Foo4.main(Foo4.java:5)\n" + "  Caused by: java.lang.Exception: Rats, you caught me\n"
+				+ "          at Resource2$CloseFailException.<init>(Resource2.java:45)\n" + "          ... 2 more\n");
+		assertEquals("Exception in thread \"main\" java.lang.Exception: Main block", getLine(doc, 0));
+		assertEquals("at Foo4.main(Foo4.java:6)", getLine(doc, 1).trim());
+		assertEquals("Suppressed: Resource2$CloseFailException: Resource ID = 1", getLine(doc, 2).trim());
+		assertEquals("at Resource2.close(Resource2.java:20)", getLine(doc, 3).trim());
+		assertEquals("at Foo4.main(Foo4.java:5)", getLine(doc, 4).trim());
+		assertEquals("Caused by: java.lang.Exception: Rats, you caught me", getLine(doc, 5).trim());
+		assertEquals("at Resource2$CloseFailException.<init>(Resource2.java:45)", getLine(doc, 6).trim());
+		assertEquals("... 2 more", getLine(doc, 7).trim());
+		checkIndentationConsistency(doc, 0);
+		// Additional indentation check. Since cause is linked to suppressed exception it must be greater indented as primary exception stuff.
+		assertNotEquals(getLineIndentation(getLine(doc, 0)), getLineIndentation(getLine(doc, 5)));
+		assertEquals(getLineIndentation(getLine(doc, 2)), getLineIndentation(getLine(doc, 5)));
+		assertNotEquals(getLineIndentation(getLine(doc, 1)), getLineIndentation(getLine(doc, 6)));
+		assertEquals(getLineIndentation(getLine(doc, 3)), getLineIndentation(getLine(doc, 6)));
+		assertEquals(getLineIndentation(getLine(doc, 4)), getLineIndentation(getLine(doc, 7)));
+		assertTrue(getLineIndentation(getLine(doc, 5)) > getLineIndentation(getLine(doc, 0)));
+		assertTrue(getLineIndentation(getLine(doc, 6)) > getLineIndentation(getLine(doc, 1)));
+	}
+
+	/** Test formatting the rare [CIRCULAR REFERENCE:...] entry. */
+	public void testFormatCircular() {
+		IDocument doc = consoleDocumentFormatted("Exception in thread \"main\" Stacktrace$BadException\n"
+				+ "at Stacktrace.main\n(Stacktrace.java:4)\n" + " Caused by: Stacktrace$BadExceptionCompanion: Stacktrace$BadException\n"
+				+ "   at Stacktrace$BadException.<init>(Stacktrace.java:10)\n" + "    ... 1 more\n"
+				+ "  [CIRCULAR REFERENCE:Stacktrace$BadException]");
+		assertEquals("Exception in thread \"main\" Stacktrace$BadException", getLine(doc, 0));
+		assertEquals("at Stacktrace.main(Stacktrace.java:4)", getLine(doc, 1).trim());
+		assertEquals("Caused by: Stacktrace$BadExceptionCompanion: Stacktrace$BadException", getLine(doc, 2));
+		assertEquals("at Stacktrace$BadException.<init>(Stacktrace.java:10)", getLine(doc, 3).trim());
+		assertEquals("... 1 more", getLine(doc, 4).trim());
+		assertEquals("[CIRCULAR REFERENCE:Stacktrace$BadException]", getLine(doc, 5).trim());
+		checkIndentationConsistency(doc, 0);
+	}
+
+	/** Test formatting stack trace from an ant execution. (output mixed with ant prefixes) */
+	public void testFormatAnt() {
+		IDocument doc = consoleDocumentFormatted("[java] !ENTRY org.eclipse.debug.core 4 120 2005-01-11 03:02:30.321\n"
+				+ "     [java] !MESSAGE An exception occurred while dispatching debug events.\n" + "     [java] !STACK 0\n"
+				+ "     [java] java.lang.NullPointerException\n" + "     [java] 	at \n"
+				+ "org.eclipse.debug.internal.ui.views.console.ProcessConsole.closeStreams\n" + "(ProcessConsole.java:364)\n" + "     [java] 	at \n"
+				+ "org.eclipse.debug.internal.ui.views.console.ProcessConsole.handleDebugEvents\n" + "(ProcessConsole.java:438)\n"
+				+ "     [java] 	at org.eclipse.debug.core.DebugPlugin$EventNotifier.run\n" + "(DebugPlugin.java:1043)");
+		assertEquals("[java] !ENTRY org.eclipse.debug.core 4 120 2005-01-11 03:02:30.321", getLine(doc, 0));
+		assertEquals("[java] !MESSAGE An exception occurred while dispatching debug events.", getLine(doc, 1));
+		assertEquals("[java] !STACK 0", getLine(doc, 2));
+		assertEquals("[java] java.lang.NullPointerException", getLine(doc, 3));
+		assertEquals("at org.eclipse.debug.internal.ui.views.console.ProcessConsole.closeStreams(ProcessConsole.java:364)", getLine(doc, 4).replace("[java]", "").trim());
+		assertEquals("at org.eclipse.debug.internal.ui.views.console.ProcessConsole.handleDebugEvents(ProcessConsole.java:438)", getLine(doc, 5).replace("[java]", "").trim());
+		assertEquals("at org.eclipse.debug.core.DebugPlugin$EventNotifier.run(DebugPlugin.java:1043)", getLine(doc, 6).replace("[java]", "").trim());
+		checkIndentationConsistency(doc, 3);
+	}
+
 	private IDocument consoleDocumentWithText(String text) throws InterruptedException {
 		IDocument document = fConsole.getDocument();
 		document.set(text);
@@ -277,6 +502,90 @@
 		return document;
 	}
 
+	private String getLine(IDocument doc, int line) {
+		IRegion lineInfo;
+		try {
+			lineInfo = doc.getLineInformation(line);
+			return doc.get(lineInfo.getOffset(), lineInfo.getLength());
+		} catch (BadLocationException ex) {
+			return null;
+		}
+	}
+
+	/**
+	 * Do some tests on the stack trace indentation. No hardcoded valued just some general assumptions.
+	 *
+	 * @param doc
+	 *            document to test
+	 * @param startLine
+	 *            first line to check
+	 */
+	private void checkIndentationConsistency(IDocument doc, int startLine) {
+		boolean firstSuppress = true;
+		int lastIndent = -1;
+		// Remember how the next line's indentation can differ from the previous.
+		// -1 -> less indented
+		// 0 -> equal
+		// 1 -> more indented
+		int allowedIndentChange = 1;
+		for (int i = startLine, lineCount = doc.getNumberOfLines(); i < lineCount; i++) {
+			String line = getLine(doc, i);
+			line = line.replaceFirst("^\\[[^\\s\\]]+\\] ", ""); // remove and prefix if any
+			if (i != 0) { // first line can be empty
+				assertNotEquals("Empty line " + i, "", line);
+			}
+			assertFalse("Trailing whitespace in line " + i, RIGHT_INDENT.matcher(line).find());
+
+			boolean causedBy = line.trim().startsWith("Caused by: ");
+			boolean suppressed = line.trim().startsWith("Suppressed: ");
+			if (causedBy || (suppressed && !firstSuppress)) {
+				allowedIndentChange = -1;
+			}
+
+			int lineIndent = getLineIndentation(line);
+			if (allowedIndentChange < 0) {
+				assertTrue("Wrong indented line " + i + ": " + lastIndent + " > " + lineIndent, lastIndent > lineIndent);
+			} else if (allowedIndentChange == 0) {
+				assertEquals("Mixed indentation in line " + i, lastIndent, lineIndent);
+			} else if (allowedIndentChange > 0) {
+				assertTrue("Wrong indented line " + i + ": " + lastIndent + " < " + lineIndent, lastIndent < lineIndent);
+			}
+			lastIndent = lineIndent;
+			allowedIndentChange = 0;
+			if (causedBy || suppressed || i == startLine) {
+				allowedIndentChange = 1;
+			}
+			firstSuppress &= !suppressed;
+		}
+	}
+
+	private int getLineIndentation(String line) {
+		int tabSize = 4;
+		String indent = "";
+		Matcher m = LEFT_INDENT.matcher(line);
+		if (m.find()) {
+			indent = m.group();
+		}
+		int tabCount = indent.length() - indent.replace("\t", "").length();
+		return indent.length() + (tabSize - 1) * tabCount;
+	}
+
+	/**
+	 * Set given text, invoke formatting and wait until finished.
+	 *
+	 * @param text
+	 *            new console text
+	 * @return the consoles document
+	 */
+	private IDocument consoleDocumentFormatted(String text) {
+		IDocument document = fConsole.getDocument();
+		document.set(text);
+		fConsole.format();
+		// wait for document being formatted
+		TestUtil.waitForJobs(getName(), 30, 1000);
+		return document;
+	}
+
 	private String[] linkTextsAtPositions(int... offsets) throws BadLocationException {
 		IDocument document = fConsole.getDocument();
 
diff --git a/org.eclipse.jdt.debug.ui/ui/org/eclipse/jdt/internal/debug/ui/console/JavaStackTraceConsole.java b/org.eclipse.jdt.debug.ui/ui/org/eclipse/jdt/internal/debug/ui/console/JavaStackTraceConsole.java
index 6ab52b4..5b80944 100644
--- a/org.eclipse.jdt.debug.ui/ui/org/eclipse/jdt/internal/debug/ui/console/JavaStackTraceConsole.java
+++ b/org.eclipse.jdt.debug.ui/ui/org/eclipse/jdt/internal/debug/ui/console/JavaStackTraceConsole.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2000, 2019 IBM Corporation and others.
+ * Copyright (c) 2000, 2020 IBM Corporation and others.
  *
  * This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -12,6 +12,7 @@
  *     IBM Corporation - initial API and implementation
  *     Paul Pazderski - Bug 546900: Fix IO handling in JavaStacktraceConsole
  *     Paul Pazderski - Bug 343023: Clear the initial stack trace console message on first edit
+ *     Paul Pazderski - Bug 304219: Recognize more typical stack trace keywords for formatting
  *******************************************************************************/
 package org.eclipse.jdt.internal.debug.ui.console;
 
@@ -21,6 +22,8 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.StringTokenizer;
 
 import org.eclipse.core.runtime.IProgressMonitor;
@@ -80,6 +83,10 @@
     public final static String CONSOLE_TYPE = "javaStackTraceConsole"; //$NON-NLS-1$
     public final static String FILE_NAME = JDIDebugUIPlugin.getDefault().getStateLocation().toOSString() + File.separator + "stackTraceConsole.txt"; //$NON-NLS-1$
 
+	private static final String NL = "\n"; //$NON-NLS-1$
+	private static final String INDENT_STR = "    "; //$NON-NLS-1$
+	private static final int INDENT_WIDTH = 4;
+
     private JavaStackTraceConsolePartitioner partitioner = new JavaStackTraceConsolePartitioner();
     private IPropertyChangeListener propertyListener = new IPropertyChangeListener() {
         @Override
@@ -234,98 +241,136 @@
 
     }
 
-    /**
-     * Underlying format operation
-     * @param trace the stack trace to format
-     * @return the formatted stack trace for this console
-     */
-    private String format(String trace) {
-        StringTokenizer tokenizer = new StringTokenizer(trace, " \t\n\r\f", true); //$NON-NLS-1$
-        StringBuilder formattedTrace = new StringBuilder();
+	/**
+	 * Underlying format operation
+	 *
+	 * @param trace
+	 *            the stack trace to format
+	 * @return the formatted stack trace for this console
+	 */
+	private String format(String trace) {
+		StringTokenizer tokenizer = new StringTokenizer(trace, " \t\n\r\f", true); //$NON-NLS-1$
+		StringBuilder formattedTrace = new StringBuilder(trace.length());
 
-        boolean insideAt = false;
-        boolean newLine = true;
-        int pendingSpaces = 0;
-        boolean antTrace = false;
+		boolean insideAt = false;
+		boolean newLine = true;
+		int pendingSpaces = 0;
+		boolean antTrace = false;
+		int depth = 1;
+		// Block depth map is used to find the most likely indentation for a Caused.
+		// In combination with Suppressed the correct indentation can be ambiguous.
+		// Map has indentation in number of spaces of a previous block as key and formated
+		// indentation depth used for this block as value.
+		Map<Integer, Integer> blockDepth = new HashMap<>(3);
 
-        while (tokenizer.hasMoreTokens()) {
-            String token = tokenizer.nextToken();
-            if (token.length() == 0)
-			 {
+		while (tokenizer.hasMoreTokens()) {
+			String token = tokenizer.nextToken();
+			if (token.isEmpty()) {
 				continue; // paranoid
 			}
-            char c = token.charAt(0);
-            // handle delimiters
-            switch (c) {
-            case ' ':
-                if (newLine) {
-                    pendingSpaces++;
-                } else {
-                    pendingSpaces = 1;
-                }
-                continue;
-            case '\t':
-                if (newLine) {
-                    pendingSpaces += 4;
-                } else {
-                    pendingSpaces = 1;
-                }
-                continue;
-            case '\n':
-            case '\r':
-            case '\f':
-                if (insideAt) {
-                    pendingSpaces = 1;
-                } else {
-                    pendingSpaces = 0;
-                    newLine = true;
-                }
-                continue;
-            }
-            // consider newlines only before token starting with char '\"' or
-            // token "at" or "-".
-            if (newLine || antTrace) {
-                if (c == '\"') { // leading thread name, e.g. "Worker-124"
-                                    // prio=5
-                    formattedTrace.append("\n\n"); //$NON-NLS-1$  print 2 lines to break between threads
-                } else if ("-".equals(token)) { //$NON-NLS-1$ - locked ...
-                    formattedTrace.append("\n"); //$NON-NLS-1$
-                    formattedTrace.append("    "); //$NON-NLS-1$
-                    formattedTrace.append(token);
-                    pendingSpaces = 0;
-                    continue;
-                } else if ("at".equals(token)) { //$NON-NLS-1$  at ...
-                    if (!antTrace) {
-                        formattedTrace.append("\n"); //$NON-NLS-1$
-                        formattedTrace.append("    "); //$NON-NLS-1$
-                    } else {
-                        formattedTrace.append(' ');
-                    }
-                    insideAt = true;
-                    formattedTrace.append(token);
-                    pendingSpaces = 0;
-                    continue;
-                } else if (c == '[') {
-                    if(antTrace) {
-                        formattedTrace.append("\n"); //$NON-NLS-1$
-                    }
-                    formattedTrace.append(token);
-                    pendingSpaces = 0;
-                    newLine = false;
-                    antTrace = true;
-                    continue;
-                }
-                newLine = false;
-            }
-            if (pendingSpaces > 0) {
-                for (int i = 0; i < pendingSpaces; i++) {
-                    formattedTrace.append(' ');
-                }
-                pendingSpaces = 0;
-            }
-            formattedTrace.append(token);
-            insideAt = false;
-        }
-        return formattedTrace.toString();
-    }
+			char c = token.charAt(0);
+			// handle delimiters
+			switch (c) {
+				case ' ':
+					if (newLine) {
+						pendingSpaces++;
+					} else {
+						pendingSpaces = 1;
+					}
+					continue;
+				case '\t':
+					if (newLine) {
+						pendingSpaces += INDENT_WIDTH;
+					} else {
+						pendingSpaces = 1;
+					}
+					continue;
+				case '\n':
+				case '\r':
+				case '\f':
+					if (insideAt) {
+						pendingSpaces = 1;
+					} else {
+						pendingSpaces = 0;
+						newLine = true;
+					}
+					continue;
+			}
+			// consider newlines only before token starting with char '\"' or
+			// token "at", "-", "...", "Caused by:", "Suppressed:" and "[CIRCULAR".
+			if (newLine) {
+				if (c == '\"') { // leading thread name, e.g. "Worker-124" prio=5
+					formattedTrace.append(NL + NL); // print 2 lines to break between threads
+				} else if (c == '-' // - locked <address>
+						|| "...".equals(token)) { //$NON-NLS-1$ ... xx more
+					applyIndentedToken(formattedTrace, depth, token, antTrace);
+					pendingSpaces = 0;
+					continue;
+				} else if ("at".equals(token)) { //$NON-NLS-1$ at method
+					insideAt = true;
+					applyIndentedToken(formattedTrace, depth, token, antTrace);
+					pendingSpaces = 0;
+					continue;
+				} else if (c == '[') {
+					if ("[CIRCULAR".equals(token)) { //$NON-NLS-1$ [CIRCULAR REFERENCE:toString()]
+						applyIndentedToken(formattedTrace, depth, token, antTrace);
+						pendingSpaces = 0;
+					} else {
+						if (antTrace) {
+							formattedTrace.append(NL);
+						}
+						formattedTrace.append(token);
+						pendingSpaces = 0;
+						antTrace = true;
+					}
+					continue;
+				} else if ("Caused".equals(token)) { //$NON-NLS-1$ Caused by: reason
+					// Guess depth for Cause block. This can be interpreted as if the Caused
+					// block is moved to the left until it aligns with a previous Suppressed
+					// block or hit the line begin.
+					depth = 0;
+					for (Map.Entry<Integer, Integer> block : blockDepth.entrySet()) {
+						if (block.getKey() <= pendingSpaces && block.getValue() > depth) {
+							depth = block.getValue();
+						}
+					}
+					applyIndentedToken(formattedTrace, depth, token, antTrace);
+					depth++;
+					pendingSpaces = 0;
+					continue;
+				} else if ("Suppressed:".equals(token)) { //$NON-NLS-1$ Suppressed: reason
+					if (depth >= 2) {
+						depth--;
+					}
+					blockDepth.put(pendingSpaces, depth);
+					applyIndentedToken(formattedTrace, depth, token, antTrace);
+					depth = 2;
+					pendingSpaces = 0;
+					continue;
+				}
+				newLine = false;
+			}
+			if (pendingSpaces > 0) {
+				for (int i = 0; i < pendingSpaces; i++) {
+					formattedTrace.append(' ');
+				}
+				pendingSpaces = 0;
+			}
+			formattedTrace.append(token);
+			insideAt = false;
+		}
+		return formattedTrace.toString();
+	}
+
+	private void applyIndentedToken(StringBuilder formattedTrace, int depth, String token, boolean antTrace) {
+		if (antTrace) {
+			formattedTrace.append(' ');
+		} else {
+			formattedTrace.append(NL);
+		}
+		for (int i = 0; i < depth; i++) {
+			formattedTrace.append(INDENT_STR);
+		}
+		formattedTrace.append(token);
+	}
 }