diff --git a/CHANGELOG.md b/CHANGELOG.md index b74d743..8e2f3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ============ * Your contribution here. +* [#137](https://github.com/jenkinsci/ansicolor-plugin/pull/137): Allow escape sequences to span multiple lines and support color maps that set default background/foreground colors - [@dwnusbaum](https://github.com/dwnusbaum). 0.6.1 (01/04/2019) ============ diff --git a/src/main/java/hudson/plugins/ansicolor/AnsiAttributeElement.java b/src/main/java/hudson/plugins/ansicolor/AnsiAttributeElement.java index 4adfd7d..6a3b866 100644 --- a/src/main/java/hudson/plugins/ansicolor/AnsiAttributeElement.java +++ b/src/main/java/hudson/plugins/ansicolor/AnsiAttributeElement.java @@ -1,5 +1,7 @@ package hudson.plugins.ansicolor; +import java.io.Serializable; + /** * Represents an HTML elements which maps to an ANSI attribute. * @@ -12,7 +14,9 @@ * other software or testing the HTML may be emitted otherwise. */ -class AnsiAttributeElement { +class AnsiAttributeElement implements Serializable { + private static final long serialVersionUID = 1L; + public static enum AnsiAttrType { DEFAULT, BOLD, ITALIC, UNDERLINE, STRIKEOUT, FRAMED, OVERLINE, FG, BG, FGBG } @@ -65,6 +69,11 @@ public int hashCode() { return result; } + @Override + public String toString() { + return "AnsiAttributeElement{ansiAttrType=" + ansiAttrType + ",name=" + name + ",attributes=" + attributes + "}"; + } + public static AnsiAttributeElement bold() { return new AnsiAttributeElement(AnsiAttributeElement.AnsiAttrType.BOLD, "b", ""); } diff --git a/src/main/java/hudson/plugins/ansicolor/AnsiHtmlOutputStream.java b/src/main/java/hudson/plugins/ansicolor/AnsiHtmlOutputStream.java index 7a693de..7d73208 100644 --- a/src/main/java/hudson/plugins/ansicolor/AnsiHtmlOutputStream.java +++ b/src/main/java/hudson/plugins/ansicolor/AnsiHtmlOutputStream.java @@ -30,7 +30,10 @@ import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Stack; +import javax.annotation.Nonnull; /** * Filters an outputstream of ANSI escape sequences and emits appropriate HTML elements instead. @@ -59,16 +62,27 @@ private static enum State { private boolean swapColors = false; // true if negative / inverse mode is active (esc[7m) // A Deque might be a better choice, but we are constrained by the Java 5 API. - private ArrayList openTags = new ArrayList(); + private ArrayList openTags; private OutputStream logOutput; - public AnsiHtmlOutputStream(final OutputStream os, final AnsiColorMap colorMap, - final AnsiAttributeElement.Emitter emitter) { + /** + * @param tagsToOpen A list of tags to open in the given order immediately after opening the tag for the default + * foreground/background colors (if such colors are specified by the color map) before any data is written to the + * underlying stream. + */ + /*package*/ AnsiHtmlOutputStream(final OutputStream os, final AnsiColorMap colorMap, + final AnsiAttributeElement.Emitter emitter, @Nonnull List tagsToOpen) { super(os); this.logOutput = os; this.colorMap = colorMap; this.emitter = emitter; + this.openTags = new ArrayList<>(tagsToOpen); + } + + public AnsiHtmlOutputStream(final OutputStream os, final AnsiColorMap colorMap, + final AnsiAttributeElement.Emitter emitter) { + this(os, colorMap, emitter, Collections.emptyList()); } // Debug output for plugin developers. Puts the debug message into the html page @@ -93,6 +107,13 @@ private void stopConcealing() { this.out = logOutput; } + /** + * @return A copy of the {@link AnsiAttributeElement}s which are currently opened, in order from outermost to innermost tag. + */ + /*package*/ List getOpenTags() { + return new ArrayList<>(openTags); + } + private void openTag(AnsiAttributeElement tag) throws IOException { openTags.add(tag); tag.emitOpen(emitter); @@ -198,6 +219,9 @@ public void write(int data) throws IOException { // the preamble is an ANSI escape sequence itself. if (state == State.INIT) { + List tagsToOpen = new ArrayList<>(openTags); + openTags.clear(); + Integer defaultFg = colorMap.getDefaultForeground(); Integer defaultBg = colorMap.getDefaultBackground(); @@ -207,6 +231,10 @@ public void write(int data) throws IOException { (defaultFg != null ? "color: " + colorMap.getNormal(defaultFg) + ";" : "") + "\"")); } + for (AnsiAttributeElement tag : tagsToOpen) { + openTag(tag); + } + state = State.DATA; } diff --git a/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java b/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java index e7be557..d68db7a 100644 --- a/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java +++ b/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java @@ -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,17 +53,24 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator { private static final long serialVersionUID = 1; private final String colorMapName; + private final @Nonnull List openTags; - ColorConsoleAnnotator(String colorMapName) { + ColorConsoleAnnotator(String colorMapName, List 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 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 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; @@ -68,21 +78,35 @@ class EmitterImpl implements AnsiAttributeElement.Emitter { 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,12 +126,22 @@ 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); + } + // 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 @@ -115,7 +149,7 @@ public static final class Factory extends ConsoleAnnotatorFactory { @Override public ConsoleAnnotator 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) { diff --git a/src/test/java/hudson/plugins/ansicolor/AnsiColorBuildWrapperTest.java b/src/test/java/hudson/plugins/ansicolor/AnsiColorBuildWrapperTest.java index 8a65e27..3b0d2b6 100644 --- a/src/test/java/hudson/plugins/ansicolor/AnsiColorBuildWrapperTest.java +++ b/src/test/java/hudson/plugins/ansicolor/AnsiColorBuildWrapperTest.java @@ -88,6 +88,61 @@ public boolean perform(AbstractBuild build, Launcher launcher, BuildListen }); } + @Test + 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("This text should be bold and blue\n"), + containsString("Still bold and blue\n"), + 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("
White on black\n
"), + containsString("
Bold and blue on black\n
"), + containsString("
Still bold and blue on blackBack to white on black\n
"))); + }); + } + @Issue("JENKINS-54133") @Test public void testWorkflowWrap() throws Exception {