-
-
Notifications
You must be signed in to change notification settings - Fork 86
Allow escape sequences to span multiple lines and support default fg/bg colors #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e6b8ff5
cfa7144
a95ea81
2c19b61
a2a74eb
7d8b58d
c1d7aab
50893ab
ce424f3
be7df32
31bb937
200bf2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,8 +31,11 @@ | |
| import hudson.model.Queue; | ||
| import hudson.model.Run; | ||
| import java.io.IOException; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import javax.annotation.Nonnull; | ||
| import jenkins.model.Jenkins; | ||
| import org.apache.commons.io.output.CountingOutputStream; | ||
| import org.apache.commons.io.output.NullOutputStream; | ||
|
|
@@ -50,39 +53,60 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> { | |
| private static final long serialVersionUID = 1; | ||
|
|
||
| private final String colorMapName; | ||
| private final @Nonnull List<AnsiAttributeElement> openTags; | ||
|
|
||
| ColorConsoleAnnotator(String colorMapName) { | ||
| ColorConsoleAnnotator(String colorMapName, List<AnsiAttributeElement> openTags) { | ||
| this.colorMapName = colorMapName; | ||
| LOGGER.fine("creating annotator with colorMapName=" + colorMapName); | ||
| this.openTags = openTags; | ||
| LOGGER.log(Level.FINE, "creating annotator with colorMapName={0} openTags={1}", new Object[] { colorMapName, openTags }); | ||
| } | ||
|
|
||
| ColorConsoleAnnotator(String colorMapName) { | ||
| this(colorMapName, Collections.emptyList()); | ||
| } | ||
|
|
||
| @Override | ||
| public ConsoleAnnotator<Object> annotate(Object context, MarkupText text) { | ||
| String s = text.getText(); | ||
| if (s.indexOf('\u001B') != -1) { | ||
| AnsiColorMap colorMap = Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getColorMap(colorMapName); | ||
| List<AnsiAttributeElement> nextOpenTags = openTags; | ||
| AnsiColorMap colorMap = Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getColorMap(colorMapName); | ||
| if (s.indexOf('\u001B') != -1 || !openTags.isEmpty() || colorMap.getDefaultBackground() != null || colorMap.getDefaultForeground() != null) { | ||
| CountingOutputStream outgoing = new CountingOutputStream(new NullOutputStream()); | ||
| class EmitterImpl implements AnsiAttributeElement.Emitter { | ||
| CountingOutputStream incoming; | ||
| int adjustment; | ||
| int lastPoint = -1; // multiple HTML tags may be emitted for one control sequence | ||
| @Override | ||
| public void emitHtml(String html) { | ||
| LOGGER.log(Level.FINEST, "emitting {0} @{1}/{2}", new Object[] { html, incoming.getCount(), s.length() }); | ||
| text.addMarkup(incoming.getCount(), html); | ||
| if (incoming.getCount() != lastPoint) { | ||
| lastPoint = incoming.getCount(); | ||
| int hide = incoming.getCount() - outgoing.getCount() - adjustment; | ||
| LOGGER.log(Level.FINEST, "hiding {0} @{1}", new Object[] { hide, outgoing.getCount() + adjustment }); | ||
| text.addMarkup(outgoing.getCount() + adjustment, outgoing.getCount() + adjustment + hide, "<!--", "-->"); | ||
| adjustment += hide; | ||
| int inCount = incoming.getCount(); | ||
| int outCount = outgoing.getCount() + adjustment; | ||
| // All ANSI escapes sequences contain at least 2 bytes on modern platforms, so any HTML emitted | ||
| // directly after the first character is received is due to the initialization process of the stream and | ||
| // belongs at position 0 (i.e. default background/foreground colors). | ||
| if (inCount == 1) { | ||
| inCount = 0; | ||
| } | ||
| LOGGER.log(Level.FINEST, "emitting {0} @{1}/{2}", new Object[] { html, inCount, text.getText().length() }); | ||
| text.addMarkup(inCount, html); | ||
| if (inCount != lastPoint) { | ||
| lastPoint = inCount; | ||
| int hide = inCount - outCount; | ||
| // If openTags is not empty, but there are no escape sequences directly on this line, or if we | ||
| // are emitting closing tags when closing the stream, there is nothing to hide. | ||
| if (hide != 0) { | ||
| LOGGER.log(Level.FINEST, "hiding {0} @{1}", new Object[] { hide, outCount }); | ||
| text.addMarkup(outCount, outCount + hide, "<!--", "-->"); | ||
| adjustment += hide; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| EmitterImpl emitter = new EmitterImpl(); | ||
| CountingOutputStream incoming = new CountingOutputStream(new AnsiHtmlOutputStream(outgoing, colorMap, emitter)); | ||
| emitter.incoming = incoming; | ||
| try { | ||
| // We need to reopen tags that were still open at the end of the previous line so the stream's state is | ||
| // correct in case those tags are closed in the middle of this line. | ||
| try (AnsiHtmlOutputStream ansiOs = new AnsiHtmlOutputStream(outgoing, colorMap, emitter, openTags); | ||
| CountingOutputStream incoming = new CountingOutputStream(ansiOs)) { | ||
| emitter.incoming = incoming; | ||
| /* | ||
| * We only use AnsiHtmlOutputStream for its calls to Emitter.emitHtml when it encounters ANSI escape | ||
| * sequences; the output of the stream will be discarded. To know where to insert HTML in the MarkupText, | ||
|
|
@@ -102,20 +126,30 @@ public void emitHtml(String html) { | |
| } | ||
| incoming.write(c); | ||
| } | ||
| nextOpenTags = ansiOs.getOpenTags(); | ||
| if (colorMap.getDefaultBackground() != null || colorMap.getDefaultForeground() != null) { | ||
| // The default color scheme will be opened automatically at the beginning of the stream on the next | ||
| // line, so we don't want to duplicate it. | ||
| // AnsiHtmlOutputStream#getOpenTags makes a copy so calling `remove` is safe. | ||
| nextOpenTags.remove(0); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. I tried switching to |
||
| } | ||
| // Tags open at the end of the line are closed when the stream is closed by the try-with-resources block. | ||
| } catch (IOException x) { | ||
| LOGGER.log(Level.WARNING, null, x); | ||
| } | ||
| LOGGER.finer(() -> "\"" + StringEscapeUtils.escapeJava(s) + "\" → \"" + StringEscapeUtils.escapeJava(text.toString(true)) + "\""); | ||
| } | ||
| return this; | ||
| return openTags == nextOpenTags | ||
| ? this | ||
| : new ColorConsoleAnnotator(colorMapName, nextOpenTags); | ||
| } | ||
|
|
||
| @Extension | ||
| public static final class Factory extends ConsoleAnnotatorFactory<Object> { | ||
|
|
||
| @Override | ||
| public ConsoleAnnotator<Object> newInstance(Object context) { | ||
| LOGGER.fine("context=" + context); | ||
| LOGGER.log(Level.FINE, "context={0}", context); | ||
| if (context instanceof Run) { | ||
| ColorizedAction action = ((Run) context).getAction(ColorizedAction.class); | ||
| if (action != null) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,61 @@ public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListen | |
| }); | ||
| } | ||
|
|
||
| @Test | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I should create a JIRA issue for these and add |
||
| public void testMultilineEscapeSequence() throws Exception { | ||
| story.then(r -> { | ||
| FreeStyleProject p = r.createFreeStyleProject(); | ||
| p.getBuildWrappersList().add(new AnsiColorBuildWrapper(null)); | ||
| p.getBuildersList().add(new TestBuilder() { | ||
| @Override | ||
| public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { | ||
| listener.getLogger().println("\u001B[1;34mThis text should be bold and blue"); | ||
| listener.getLogger().println("Still bold and blue"); | ||
| listener.getLogger().println("\u001B[mThis text should be normal"); | ||
| return true; | ||
| } | ||
| }); | ||
| FreeStyleBuild b = r.buildAndAssertSuccess(p); | ||
| StringWriter writer = new StringWriter(); | ||
| b.getLogText().writeHtmlTo(0L, writer); | ||
| String html = writer.toString(); | ||
| System.out.print(html); | ||
| assertThat(html.replaceAll("<!--.+?-->", ""), | ||
| allOf( | ||
| containsString("<b><span style=\"color: #1E90FF;\">This text should be bold and blue\n</span></b>"), | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The literal |
||
| containsString("<b><span style=\"color: #1E90FF;\">Still bold and blue\n</span></b>"), | ||
| not(containsString("\u001B[m")))); | ||
| }); | ||
| } | ||
|
|
||
| @Test | ||
| public void testDefaultForegroundBackground() throws Exception { | ||
| story.then(r -> { | ||
| FreeStyleProject p = r.createFreeStyleProject(); | ||
| // The VGA ColorMap sets default foreground and background colors. | ||
| p.getBuildWrappersList().add(new AnsiColorBuildWrapper("vga")); | ||
| p.getBuildersList().add(new TestBuilder() { | ||
| @Override | ||
| public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { | ||
| listener.getLogger().println("White on black"); | ||
| listener.getLogger().println("\u001B[1;34mBold and blue on black"); | ||
| listener.getLogger().println("Still bold and blue on black\u001B[mBack to white on black"); | ||
| return true; | ||
| } | ||
| }); | ||
| FreeStyleBuild b = r.buildAndAssertSuccess(p); | ||
| StringWriter writer = new StringWriter(); | ||
| b.getLogText().writeHtmlTo(0L, writer); | ||
| String html = writer.toString(); | ||
| System.out.print(html); | ||
| assertThat(html.replaceAll("<!--.+?-->", ""), | ||
| allOf( | ||
| containsString("<div style=\"background-color: #000000;color: #AAAAAA;\">White on black\n</div>"), | ||
| containsString("<div style=\"background-color: #000000;color: #AAAAAA;\"><b><span style=\"color: #0000AA;\">Bold and blue on black\n</span></b></div>"), | ||
| containsString("<div style=\"background-color: #000000;color: #AAAAAA;\"><b><span style=\"color: #0000AA;\">Still bold and blue on black</span></b>Back to white on black\n</div>"))); | ||
| }); | ||
| } | ||
|
|
||
| @Issue("JENKINS-54133") | ||
| @Test | ||
| public void testWorkflowWrap() throws Exception { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I need more caffeine to understand this. :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this code was and still is quite confusing to me. Thankfully, the
ifstatement is covered by the newly added tests, and if it is removed the tests fail, so we should be able to avoid future regressions.