You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/10/18 05:48:03 UTC

(groovy) branch master updated: GROOVY-12429: GroovyConsole error link does not place the caret on the reported column

This is an automated email from the ASF dual-hosted git repository.

daniellansun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new d77e6df56a GROOVY-12429: GroovyConsole error link does not place the caret on the reported column
d77e6df56a is described below

commit d77e6df56a8d59ca30afbfa4c66afb9c685a22db
Author: Daniel Sun <su...@apache.org>
AuthorDate: Fri Sep 25 09:52:29 2026 +0900

    GROOVY-12429: GroovyConsole error link does not place the caret on the reported column
---
 .../main/groovy/groovy/console/ui/Console.groovy   | 180 ++++--
 .../src/spec/doc/groovy-console.adoc               |   5 +-
 .../groovy/console/ui/ConsoleErrorLinkTest.groovy  | 672 +++++++++++++++++++++
 3 files changed, 803 insertions(+), 54 deletions(-)

diff --git a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
index 2c4d7afcaf..5bd2705cee 100644
--- a/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
+++ b/subprojects/groovy-console/src/main/groovy/groovy/console/ui/Console.groovy
@@ -115,6 +115,17 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
 
     /** Prefix used for generated script names inside the console. */
     static final String DEFAULT_SCRIPT_NAME_START = 'ConsoleScript'
+
+    /**
+     * Href carried by every source link. Keyboard activation resolves
+     * {@code new URL(documentBase, href)} and drops the click when that fails.
+     * The output document has no base, so this has to be absolute. The caret
+     * position is the link element's {@link #LINK_POSITION} attribute.
+     */
+    private static final String SOURCE_LINK_HREF = 'file:///groovy-console-pos'
+
+    /** Attribute key for the {@code SourcePosition} stored on a source link. */
+    private static final String LINK_POSITION = 'groovy.console.link.position'
     private static final boolean DEBUG_GRAPE = Boolean.getBoolean('groovy.grape.debug')
 
     /** User preferences backing console settings. */
@@ -711,11 +722,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
                 def length = fileNameAndLineNumber.length()
                 def index = line.indexOf(fileNameAndLineNumber)
 
-                def style = hyperlinkStyle
-                def hrefAttr = new SimpleAttributeSet()
-                // don't pass a GString as it won't be coerced to String as addAttribute takes an Object
-                hrefAttr.addAttribute(HTML.Attribute.HREF, 'file://' + fileNameAndLineNumber)
-                style.addAttribute(HTML.Tag.A, hrefAttr)
+                int lineNumber = fileNameAndLineNumber.substring(fileNameAndLineNumber.lastIndexOf(':') + 1) as int
+                def style = linkAttributes(lineNumber, 0)
 
                 insertString(doc, initialLength, line[0..<index], stacktraceStyle)
                 insertString(doc, initialLength + index, line[index..<(index + length)], style)
@@ -1200,14 +1208,11 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
             collector.errors.each { error ->
                 if (error instanceof SyntaxErrorMessage) {
                     SyntaxException se = error.cause
-                    int errorLine = se.line
                     String message = se.originalMessage
 
                     def doc = outputArea.styledDocument
-                    Style style = createLinkStyle(errorLine)
-
                     insertString(doc, doc.length, message + ' at ', stacktraceStyle)
-                    insertString(doc, doc.length, "line: ${se.line}, column: ${se.startColumn}\n\n", style)
+                    appendErrorLocation(doc, se.line, se.startColumn)
                 } else if (error instanceof Throwable) {
                     reportException(error)
                 } else if (error instanceof ExceptionMessage) {
@@ -1230,12 +1235,8 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
                     def range = p.location.get().begin.range
                     if (range.isPresent()) {
                         def position = range.get().begin
-                        def errorLine = position.line
-                        def errorCol = position.column
-                        Style style = createLinkStyle(errorLine)
-
-                        insertString(doc, doc.length, " at ", stacktraceStyle)
-                        insertString(doc, doc.length, "line: ${errorLine}, column: ${errorCol}\n\n", style)
+                        insertString(doc, doc.length, ' at ', stacktraceStyle)
+                        appendErrorLocation(doc, position.line, position.column)
                     }
                 }
 
@@ -1257,14 +1258,44 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         }
     }
 
-    private Style createLinkStyle(int errorLine) {
-        String scriptFileName = scriptFile?.name ?: DEFAULT_SCRIPT_NAME_START
-        def style = hyperlinkStyle
+    /**
+     * Writes {@code line: N, column: M} as one source link, then a blank line.
+     * Both numbers are printed as reported. A column below 1 means the column
+     * is unknown; activating the link still places the caret on the first
+     * character of that line.
+     */
+    private void appendErrorLocation(Document doc, int line, int column) {
+        insertString(doc, doc.length, "line: ${line}, column: ${column}", linkAttributes(line, column))
+        insertString(doc, doc.length, '\n\n', stacktraceStyle)
+    }
+
+    /**
+     * A fresh attribute set per link. HTML run elements do not inherit a
+     * parent style, so the underline and color are copied onto the set.
+     * The position is its own value, so later links cannot retarget earlier ones.
+     */
+    private AttributeSet linkAttributes(int line, int column) {
+        def attrs = new SimpleAttributeSet()
+        attrs.addAttributes(hyperlinkStyle)
         def hrefAttr = new SimpleAttributeSet()
-        // don't pass a GString as it won't be coerced to String as addAttribute takes an Object
-        hrefAttr.addAttribute(HTML.Attribute.HREF, 'file://' + scriptFileName + ':' + errorLine)
-        style.addAttribute(HTML.Tag.A, hrefAttr)
-        return style
+        hrefAttr.addAttribute(HTML.Attribute.HREF, SOURCE_LINK_HREF)
+        attrs.addAttribute(HTML.Tag.A, hrefAttr)
+        attrs.addAttribute(LINK_POSITION, new SourcePosition(line, column))
+        attrs
+    }
+
+    /**
+     * Caret target stored on an output source link.
+     * Line is 1-based. A column below 1 is the first character of that line.
+     */
+    private static final class SourcePosition {
+        final int line
+        final int column
+
+        SourcePosition(int line, int column) {
+            this.line = line
+            this.column = column
+        }
     }
 
     private calcPreferredSize(a, b, c) {
@@ -2308,39 +2339,84 @@ class Console implements CaretListener, HyperlinkListener, ComponentListener, Fo
         inputEditor.redoAction.actionPerformed(evt)
     }
 
-    /** Highlights the source line referenced by an activated output hyperlink. */
+    /**
+     * Moves the editor caret to the source position on an activated output link.
+     * The position is the link element's attribute. The href is not interpreted:
+     * mouse and keyboard activation both supply the character element, while a
+     * raw href cannot carry a column and still be a legal URL.
+     */
     void hyperlinkUpdate(HyperlinkEvent e) {
-        if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) {
-            // URL of the form: file://myscript.groovy:32
-            String url = e.getURL()
-            int lineNumber = url[(url.lastIndexOf(':') + 1)..-1].toInteger()
-
-            def editor = inputEditor.textEditor
-            def text = editor.text
-
-            int newlineBefore = 0
-            int newlineAfter = 0
-            int currentLineNumber = 1
-
-            // let's find the previous and next newline surrounding the offending line
-            int i = 0
-            for (ch in text) {
-                if (ch == '\n') {
-                    currentLineNumber++
-                }
-                if (currentLineNumber == lineNumber) {
-                    newlineBefore = i
-                    def nextNewline = text.indexOf('\n', i + 1)
-                    newlineAfter = nextNewline > -1 ? nextNewline : text.length()
-                    break
-                }
-                i++
-            }
+        if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) {
+            return
+        }
+        def position = sourcePosition(e.sourceElement)
+        if (position == null) {
+            position = sourcePositionBefore(e.sourceElement)
+        }
+        if (position == null) {
+            return
+        }
+        revealSourcePosition(position)
+    }
 
-            // highlight / select the whole line
-            editor.setCaretPosition(newlineBefore)
-            editor.moveCaretPosition(newlineAfter)
+    private static SourcePosition sourcePosition(Element element) {
+        def raw = element?.attributes?.getAttribute(LINK_POSITION)
+        raw instanceof SourcePosition ? raw : null
+    }
+
+    /**
+     * Keyboard activation treats a link's exclusive end offset as inside the
+     * link, then resolves the character element at that offset. That element
+     * is the following run, so the position is on the run before it.
+     */
+    private static SourcePosition sourcePositionBefore(Element element) {
+        if (element == null || element.startOffset == 0) {
+            return null
+        }
+        sourcePosition(element.document.getCharacterElement(element.startOffset - 1))
+    }
+
+    /**
+     * Puts the caret on a 1-based line and column, counting characters the
+     * same way the status bar does. Nothing is selected. A column below 1
+     * lands on the first character of the line, and a column past the end
+     * of the line stays on that line.
+     */
+    private void revealSourcePosition(SourcePosition position) {
+        // ConsoleView assigns this from inputEditor.textEditor. The caret
+        // listener that updates the status bar is registered on this field.
+        def editor = inputArea
+        if (editor == null || position.line < 1) {
+            return
+        }
+        Element root = editor.document.defaultRootElement
+        int lineCount = root.elementCount
+        int startIndex = Math.min(position.line, lineCount) - 1
+        // setCaretPosition collapses any existing selection onto this offset.
+        editor.caretPosition = offsetOnLine(root.getElement(startIndex), position.column)
+        // mousePressed focuses the output pane. Focus the editor after that
+        // event so the caret is visible.
+        def focusEditor = { editor.requestFocusInWindow() }
+        SwingUtilities.invokeLater(focusEditor)
+    }
+
+    /** Last caret offset that still belongs to this line, before its line break. */
+    private static int lineContentEnd(Element lineElement) {
+        Math.max(lineElement.startOffset, lineElement.endOffset - 1)
+    }
+
+    /** Maps a 1-based column onto a line, clamped so it cannot spill onto the next line. */
+    private static int offsetOnLine(Element lineElement, int column) {
+        int start = lineElement.startOffset
+        int end = lineContentEnd(lineElement)
+        if (column < 1) {
+            return start
+        }
+        long offset = (long) start + column - 1L
+        if (offset > end) {
+            return end
         }
+        (int) offset
     }
 
     /** No-op component listener implementation. */
diff --git a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
index e8b19811b8..871f99e0f5 100644
--- a/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
+++ b/subprojects/groovy-console/src/spec/doc/groovy-console.adoc
@@ -165,8 +165,9 @@ image:{reldir_console}/assets/img/gconsole-dark-theme.png[Dark Theme]
 * You can modify the classpath with which the script in the console is
 being run by adding a new JAR or a directory to the classpath from the
 `Script` menu
-* Error hyperlinking from the output area when a compilation error is
-expected or when an exception is thrown
+* Clicking the line and column of a compilation error, or a stack-trace
+reference to the script being edited, moves the caret to that position
+without selecting any characters
 
 [[GroovyConsole-EmbeddingtheConsole]]
 == Embedding the Console
diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleErrorLinkTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleErrorLinkTest.groovy
new file mode 100644
index 0000000000..9a0488f820
--- /dev/null
+++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/ConsoleErrorLinkTest.groovy
@@ -0,0 +1,672 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.console.ui
+
+import com.github.javaparser.JavaToken
+import com.github.javaparser.ParseProblemException
+import com.github.javaparser.Problem
+import com.github.javaparser.StaticJavaParser
+import com.github.javaparser.TokenRange
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.ErrorCollector
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.control.messages.SyntaxErrorMessage
+import org.codehaus.groovy.syntax.SyntaxException
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import javax.swing.JLabel
+import javax.swing.JTextPane
+import javax.swing.SwingUtilities
+import javax.swing.event.HyperlinkEvent
+import javax.swing.text.AttributeSet
+import javax.swing.text.DefaultStyledDocument
+import javax.swing.text.Document
+import javax.swing.text.Element
+import javax.swing.text.SimpleAttributeSet
+import javax.swing.text.Style
+import javax.swing.text.StyleConstants
+import javax.swing.text.StyleContext
+import javax.swing.text.html.HTML
+import java.awt.Color
+import java.util.prefs.Preferences
+
+/**
+ * Activating a location in the output pane moves the editor caret to that
+ * line and column without selecting any characters. The status bar counts
+ * the same character columns.
+ */
+class ConsoleErrorLinkTest {
+
+    private Preferences previousPrefs
+    private Preferences testPrefs
+    private Console console
+
+    @BeforeEach
+    void setUp() {
+        previousPrefs = Console.prefs
+        testPrefs = Preferences.userRoot().node('groovy/console/error-link-test')
+        testPrefs.clear()
+        Console.prefs = testPrefs
+        console = new Console()
+        console.detachedOutput = false
+        console.statusLabel = new JLabel()
+        console.rowNumAndColNum = new JLabel('1:1')
+        console.inputArea = new JTextPane()
+        console.rootElement = console.inputArea.document.defaultRootElement
+        console.inputArea.addCaretListener(console)
+        wireOutput(console)
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (console != null) {
+            Console.consoleControllers.remove(console)
+        }
+        Console.prefs = previousPrefs
+        testPrefs?.removeNode()
+    }
+
+    @Test
+    void testUnclosedStringLinkPlacesCaretOnTheReportedColumn() {
+        onEdt {
+            // println '123
+            // 123456789
+            setEditorText("println '123")
+            reportCompilationFailure("println '123")
+
+            def link = linkContaining('column: 9')
+            assert link.text.contains('line: 1, column: 9')
+            assert link.underlined
+
+            console.inputArea.select(0, 4)
+            activate(link)
+            assert console.rowNumAndColNum.text == '1:9'
+            assert console.inputArea.caretPosition == 8
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testErrorOnALaterLineDoesNotStartAtThePreviousNewline() {
+        onEdt {
+            setEditorText("println 1\nprintln '123")
+            reportCompilationFailure("println 1\nprintln '123")
+
+            activate(linkContaining('line: 2, column: 9'))
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(1)
+            assert console.rowNumAndColNum.text == '2:9'
+            assert console.inputArea.caretPosition == line.startOffset + 8
+            assert charAtCaret(console.inputArea) == "'"
+            assert console.inputArea.caretPosition != line.startOffset - 1
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testEachErrorLinkKeepsItsOwnColumn() {
+        onEdt {
+            setEditorText("println '123\nfoo(")
+            reportPositions("println '123\nfoo(",
+                    new SyntaxException('Unclosed string literal', 1, 9, 1, 10),
+                    new SyntaxException('Unexpected character', 2, 4, 2, 5))
+
+            def found = locationLinks()
+            assert found.size() == 2
+            assert found[0].href == 'file:///groovy-console-pos'
+            assert found[0].href == found[1].href
+            assert found[0].element != found[1].element
+
+            activate(found[0])
+            assert console.rowNumAndColNum.text == '1:9'
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+
+            activate(found[1])
+            assert console.rowNumAndColNum.text == '2:4'
+            assert charAtCaret(console.inputArea) == '('
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testStackTraceLinkPlacesCaretAtTheStartOfTheReferencedLine() {
+        onEdt {
+            setEditorText('alpha\nbeta\ngamma')
+            console.appendStacktrace('\tat Script.main(ConsoleScript0.groovy:2)\n')
+
+            def link = links(console.outputArea.document).find { it.text.contains('ConsoleScript0.groovy:2') }
+            assert link != null
+            activate(link)
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(1)
+            assert console.inputArea.caretPosition == line.startOffset
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '2:1'
+        }
+    }
+
+    @Test
+    void testTabCountsAsOneColumn() {
+        onEdt {
+            setEditorText("\t'abc")
+            reportCompilationFailure("\t'abc")
+
+            def link = linkContaining('column:')
+            def column = (link.text =~ /column: (\d+)/)[0][1]
+            activate(link)
+
+            assert console.rowNumAndColNum.text == "1:$column"
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+            assert link.underlined
+        }
+    }
+
+    @Test
+    void testColumnPastTheEndOfTheLineStaysOnThatLine() {
+        onEdt {
+            reportPositions('ab\ncd', new SyntaxException('bad', 1, 50))
+
+            activate(locationLinks()[0])
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(0)
+            assert console.inputArea.caretPosition == line.endOffset - 1
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '1:3'
+        }
+    }
+
+    @Test
+    void testHugeColumnDoesNotWrapTheCaret() {
+        onEdt {
+            reportPositions('ab', new SyntaxException('bad', 1, Integer.MAX_VALUE, 1, Integer.MAX_VALUE))
+
+            activate(locationLinks()[0])
+
+            assert console.inputArea.caretPosition == 2
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '1:3'
+        }
+    }
+
+    @Test
+    void testEndOfTheErrorDoesNotSelectPastTheReportedColumn() {
+        onEdt {
+            reportPositions('abcdef\nxyz', new SyntaxException('bad', 1, 2, 2, 3))
+
+            activate(locationLinks()[0])
+
+            assert console.rowNumAndColNum.text == '1:2'
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testEndLineBeyondTheDocumentDoesNotMoveTheCaret() {
+        onEdt {
+            reportPositions('abcdef\nxyz', new SyntaxException('bad', 1, 2, 9, 2))
+
+            activate(locationLinks()[0])
+
+            assert console.rowNumAndColNum.text == '1:2'
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testEndPositionBeforeTheStartDoesNotSelectBackwards() {
+        onEdt {
+            reportPositions('abcdef\nxyz', new SyntaxException('bad', 2, 2, 1, 2))
+
+            activate(locationLinks()[0])
+
+            assert console.rowNumAndColNum.text == '2:2'
+            assert charAtCaret(console.inputArea) == 'y'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testLineBeyondTheDocumentClampsToTheLastLine() {
+        onEdt {
+            reportPositions('ab\ncd', new SyntaxException('bad', 50, 2, 50, 3))
+
+            activate(locationLinks()[0])
+
+            assert console.rowNumAndColNum.text == '2:2'
+            assert charAtCaret(console.inputArea) == 'd'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testAbsentColumnPlacesTheCaretAtTheStartOfTheLine() {
+        onEdt {
+            reportPositions('alpha\nbeta', new SyntaxException('bad', 2, 0, 2, 4))
+
+            def link = locationLinks()[0]
+            assert link.text.contains('line: 2, column: 0')
+            activate(link)
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(1)
+            assert console.inputArea.caretPosition == line.startOffset
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '2:1'
+        }
+    }
+
+    @Test
+    void testUnknownColumnPlacesTheCaretAtTheStartOfTheLine() {
+        onEdt {
+            reportPositions('alpha\nbeta', new SyntaxException('bad', 2, -1, 2, -1))
+
+            def link = locationLinks()[0]
+            assert link.text.contains('line: 2, column: -1')
+            activate(link)
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(1)
+            assert console.inputArea.caretPosition == line.startOffset
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '2:1'
+        }
+    }
+
+    @Test
+    void testZeroLineNumberDoesNotMoveTheCaret() {
+        onEdt {
+            setEditorText('alpha\nbeta')
+            console.inputArea.select(1, 3)
+            console.appendStacktrace('\tat Script.main(ConsoleScript0.groovy:0)\n')
+
+            activate(links(console.outputArea.document).find { it.text.contains(':0') })
+
+            assert console.inputArea.caretPosition == 3
+            assert console.inputArea.selectedText == 'lp'
+        }
+    }
+
+    @Test
+    void testJavaParseProblemPlacesCaretOnTheReportedColumn() {
+        onEdt {
+            String source = 'class {'
+            setEditorText(source)
+            try {
+                StaticJavaParser.parse(source)
+                throw new AssertionError('expected a Java parse problem')
+            } catch (ParseProblemException e) {
+                console.finishException(e, false)
+            }
+
+            activate(locationLinks()[0])
+
+            assert console.rowNumAndColNum.text == '1:1'
+            assert console.inputArea.caretPosition == 0
+            assert charAtCaret(console.inputArea) == 'c'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testKeyboardActivationAtTheEndOfTheLinkStillMovesTheCaret() {
+        onEdt {
+            setEditorText("println '123")
+            reportCompilationFailure("println '123")
+            def link = linkContaining('column: 9')
+            def doc = (DefaultStyledDocument) console.outputArea.document
+            Element boundary = doc.getCharacterElement(link.element.endOffset)
+            assert boundary != link.element
+
+            console.inputArea.caretPosition = 0
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null,
+                    link.href,
+                    boundary))
+
+            assert console.rowNumAndColNum.text == '1:9'
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testDescriptionDoesNotOverrideTheLinkPosition() {
+        onEdt {
+            setEditorText("println '123")
+            reportCompilationFailure("println '123")
+            def link = linkContaining('column: 9')
+
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null,
+                    'file://ignored.groovy:4',
+                    link.element))
+
+            assert console.rowNumAndColNum.text == '1:9'
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testPositionSurvivesAFontSizeMerge() {
+        onEdt {
+            setEditorText("println '123")
+            reportCompilationFailure("println '123")
+            def link = linkContaining('column: 9')
+            def size = new SimpleAttributeSet()
+            StyleConstants.setFontSize(size, 20)
+            console.outputArea.document.setCharacterAttributes(0, console.outputArea.document.length, size, false)
+
+            activate(link)
+
+            assert console.rowNumAndColNum.text == '1:9'
+            assert charAtCaret(console.inputArea) == "'"
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testColumnWithoutAnEndStillPlacesOnlyTheCaret() {
+        onEdt {
+            reportPositions('abcdef', new SyntaxException('bad', 1, 3, 0, 0))
+            activate(locationLinks()[0])
+            assert console.rowNumAndColNum.text == '1:3'
+            assert charAtCaret(console.inputArea) == 'c'
+            assertCaretOnly(console.inputArea)
+
+            console.outputArea.text = ''
+            reportPositions('abcdef', new SyntaxException('bad', 1, 3, 1, 0))
+            activate(locationLinks()[0])
+            assert console.rowNumAndColNum.text == '1:3'
+            assert charAtCaret(console.inputArea) == 'c'
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testJavaProblemWithoutAUsableRangeDoesNotMoveTheCaret() {
+        onEdt {
+            setEditorText('class {')
+            console.inputArea.caretPosition = 4
+
+            console.finishException(new ParseProblemException(new IllegalStateException('plain')), false)
+            assert locationLinks().empty
+            assert console.inputArea.caretPosition == 4
+
+            console.outputArea.text = ''
+            console.finishException(new ParseProblemException([
+                    new Problem('no range', new TokenRange(JavaToken.INVALID, JavaToken.INVALID), null)
+            ]), false)
+            assert locationLinks().empty
+            assert console.inputArea.caretPosition == 4
+        }
+    }
+
+    @Test
+    void testJavaProblemWithoutAnEndRangePlacesTheCaretOnly() {
+        onEdt {
+            String source = 'class {'
+            setEditorText(source)
+            TokenRange located
+            try {
+                StaticJavaParser.parse(source)
+                throw new AssertionError('expected a Java parse problem')
+            } catch (ParseProblemException e) {
+                located = e.problems[0].location.get()
+            }
+            console.finishException(new ParseProblemException([
+                    new Problem('partial', new TokenRange(located.begin, JavaToken.INVALID), null)
+            ]), false)
+
+            activate(locationLinks()[0])
+            assert console.rowNumAndColNum.text == '1:1'
+            assert console.inputArea.caretPosition == 0
+            assertCaretOnly(console.inputArea)
+        }
+    }
+
+    @Test
+    void testSeveralJavaProblemsAreAllReported() {
+        onEdt {
+            setEditorText('class {')
+            console.inputArea.caretPosition = 2
+            console.finishException(new ParseProblemException([
+                    new Problem('one', null, null),
+                    new Problem('two', null, null)
+            ]), false)
+
+            assert locationLinks().empty
+            def text = console.outputArea.document.getText(0, console.outputArea.document.length)
+            assert text.contains('one')
+            assert text.contains('two')
+            assert text.contains('2 compilation errors')
+            assert console.inputArea.caretPosition == 2
+        }
+    }
+
+    @Test
+    void testStackTraceLinksOnlyTheCurrentScript() {
+        onEdt {
+            setEditorText('alpha\nbeta')
+            console.scriptFile = new File('Foo.java')
+            console.appendStacktrace('\tat com.acme.Foo.bar(Foo.java:2)\nnot a frame\n\tat java.lang.Thread.run(Thread.java:1)\n')
+
+            def found = links(console.outputArea.document)
+            assert found.size() == 1
+            assert found[0].text.contains('Foo.java:2')
+            activate(found[0])
+
+            Element line = console.inputArea.document.defaultRootElement.getElement(1)
+            assert console.inputArea.caretPosition == line.startOffset
+            assert charAtCaret(console.inputArea) == 'b'
+            assertCaretOnly(console.inputArea)
+            assert console.rowNumAndColNum.text == '2:1'
+        }
+    }
+
+    @Test
+    void testAppendStacktraceWithoutAnOutputPaneDoesNothing() {
+        onEdt {
+            console.outputArea = null
+            console.appendStacktrace('\tat Script.main(ConsoleScript0.groovy:1)\n')
+        }
+    }
+
+    @Test
+    void testIgnoredEventsLeaveTheCaretAlone() {
+        onEdt {
+            setEditorText("println '123")
+            reportCompilationFailure("println '123")
+            def link = linkContaining('column: 9')
+            console.inputArea.select(0, 2)
+
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ENTERED,
+                    null,
+                    link.href,
+                    link.element))
+            assert console.inputArea.caretPosition == 2
+            assert console.inputArea.selectedText == 'pr'
+
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null))
+            assert console.inputArea.caretPosition == 2
+            assert console.inputArea.selectedText == 'pr'
+
+            Element plain = console.inputArea.document.defaultRootElement.getElement(0)
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null,
+                    link.href,
+                    plain))
+            assert console.inputArea.caretPosition == 2
+            assert console.inputArea.selectedText == 'pr'
+
+            setEditorText("alpha\nbeta")
+            console.inputArea.caretPosition = 1
+            Element laterLine = console.inputArea.document.defaultRootElement.getElement(1)
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null,
+                    link.href,
+                    laterLine))
+            assert console.inputArea.caretPosition == 1
+
+            console.inputArea = null
+            console.hyperlinkUpdate(new HyperlinkEvent(
+                    console.outputArea,
+                    HyperlinkEvent.EventType.ACTIVATED,
+                    null,
+                    link.href,
+                    link.element))
+        }
+    }
+
+    private static void wireOutput(Console console) {
+        JTextPane output = new JTextPane()
+        output.contentType = 'text/html'
+        DefaultStyledDocument doc = (DefaultStyledDocument) output.styledDocument
+        Style regular = doc.addStyle('regular', StyleContext.defaultStyleContext.getStyle(StyleContext.DEFAULT_STYLE))
+        console.commandStyle = doc.addStyle('command', regular)
+        console.stacktraceStyle = doc.addStyle('stacktrace', regular)
+        console.hyperlinkStyle = doc.addStyle('hyperlink', regular)
+        StyleConstants.setForeground(console.hyperlinkStyle, Color.BLUE)
+        StyleConstants.setUnderline(console.hyperlinkStyle, true)
+        console.promptStyle = doc.addStyle('prompt', regular)
+        console.outputStyle = doc.addStyle('output', regular)
+        console.resultStyle = doc.addStyle('result', regular)
+        console.outputArea = output
+    }
+
+    private void setEditorText(String text) {
+        console.inputArea.text = text
+    }
+
+    private void reportCompilationFailure(String source) {
+        try {
+            console.shell.evaluate(source)
+            throw new AssertionError('expected a compilation failure')
+        } catch (MultipleCompilationErrorsException e) {
+            console.finishException(e, false)
+        }
+    }
+
+    private void reportPositions(String source, SyntaxException... errors) {
+        setEditorText(source)
+        def config = new CompilerConfiguration()
+        def collector = new ErrorCollector(config)
+        def unit = new SourceUnit('ConsoleScript.groovy', source, config, new GroovyClassLoader(), collector)
+        errors.each { collector.addErrorAndContinue(new SyntaxErrorMessage(it, unit)) }
+        console.finishException(new MultipleCompilationErrorsException(collector), false)
+    }
+
+    private void activate(Map link) {
+        console.hyperlinkUpdate(new HyperlinkEvent(
+                console.outputArea,
+                HyperlinkEvent.EventType.ACTIVATED,
+                null,
+                link.href,
+                link.element))
+    }
+
+    private List<Map> locationLinks() {
+        links(console.outputArea.document).findAll { it.text.contains('line:') }
+    }
+
+    private Map linkContaining(String snippet) {
+        def match = links(console.outputArea.document).find { it.text.contains(snippet) }
+        assert match != null : "no link containing '${snippet}' in\n${console.outputArea.document.getText(0, console.outputArea.document.length)}"
+        match
+    }
+
+    private static List<Map> links(Document doc) {
+        DefaultStyledDocument styled = (DefaultStyledDocument) doc
+        List<Map> found = []
+        int pos = 0
+        while (pos < styled.length) {
+            Element elem = styled.getCharacterElement(pos)
+            def anchor = elem.attributes.getAttribute(HTML.Tag.A)
+            if (anchor instanceof AttributeSet) {
+                def href = anchor.getAttribute(HTML.Attribute.HREF)
+                if (href != null) {
+                    String text = styled.getText(elem.startOffset, Math.max(0, elem.endOffset - elem.startOffset))
+                    found << [
+                            href: href.toString(),
+                            text: text,
+                            element: elem,
+                            underlined: StyleConstants.isUnderline(elem.attributes)
+                    ]
+                }
+            }
+            int next = elem.endOffset
+            if (next <= pos) {
+                break
+            }
+            pos = next
+        }
+        found
+    }
+
+    private static void assertCaretOnly(JTextPane editor) {
+        assert editor.selectionStart == editor.selectionEnd
+        assert editor.selectedText == null
+    }
+
+    private static String charAtCaret(JTextPane editor) {
+        int pos = editor.caretPosition
+        if (pos < 0 || pos >= editor.document.length) {
+            return ''
+        }
+        editor.document.getText(pos, 1)
+    }
+
+    private static void onEdt(Closure body) {
+        Throwable thrown = null
+        SwingUtilities.invokeAndWait {
+            try {
+                body()
+            } catch (Throwable t) {
+                thrown = t
+            }
+        }
+        if (thrown != null) {
+            throw thrown
+        }
+        // hyperlinkUpdate posts the editor focus request to the EDT
+        SwingUtilities.invokeAndWait { }
+    }
+}