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 { }
+ }
+}