diff --git a/src/it/projects/tree-tokens/invoker.properties b/src/it/projects/tree-tokens/invoker.properties new file mode 100644 index 000000000..229c8b56b --- /dev/null +++ b/src/it/projects/tree-tokens/invoker.properties @@ -0,0 +1,23 @@ +# 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. + +invoker.goals.1 = ${project.groupId}:${project.artifactId}:${project.version}:tree -DoutputFile=auto.txt +invoker.goals.2 = ${project.groupId}:${project.artifactId}:${project.version}:tree -Dtokens=extended -DoutputFile=extended.txt -DoutputEncoding=UTF-8 +invoker.goals.3 = ${project.groupId}:${project.artifactId}:${project.version}:tree -Dtokens=whitespace -DoutputFile=whitespace.txt +invoker.goals.4 = ${project.groupId}:${project.artifactId}:${project.version}:tree -Dtokens=standard -DoutputFile=standard.txt +invoker.goals.5 = ${project.groupId}:${project.artifactId}:${project.version}:tree -Dtokens=invalid -DoutputFile=invalid.txt +invoker.goals.6 = ${project.groupId}:${project.artifactId}:${project.version}:tree -l tree.log -Dstyle.color=always diff --git a/src/it/projects/tree-tokens/pom.xml b/src/it/projects/tree-tokens/pom.xml new file mode 100644 index 000000000..2a69a8595 --- /dev/null +++ b/src/it/projects/tree-tokens/pom.xml @@ -0,0 +1,48 @@ + + + + + 4.0.0 + + org.apache.maven.its.dependency + tree-tokens + 1.0-SNAPSHOT + + Test + + Test automatic and explicitly selected dependency tree tokens + + + + UTF-8 + + + + + org.apache.commons + commons-lang3 + 3.18.0 + true + + + + diff --git a/src/it/projects/tree-tokens/verify.groovy b/src/it/projects/tree-tokens/verify.groovy new file mode 100644 index 000000000..849aa1de0 --- /dev/null +++ b/src/it/projects/tree-tokens/verify.groovy @@ -0,0 +1,33 @@ +/* + * 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. + */ + +import static org.assertj.core.api.Assertions.assertThat + +String dependency = "org.apache.commons:commons-lang3:jar:3.18.0:compile" +String ascii = new File(basedir, "auto.txt").getText("UTF-8") +assertThat(ascii).contains("\\- " + dependency).doesNotContain("├", "└", "─", "│") +assertThat(new File(basedir, "standard.txt").getText("UTF-8")).isEqualTo(ascii) +assertThat(new File(basedir, "invalid.txt").getText("UTF-8")).isEqualTo(ascii) +assertThat(new File(basedir, "extended.txt").getText("UTF-8")).contains("└─ " + dependency) +assertThat(new File(basedir, "whitespace.txt").getText("UTF-8")).contains(" " + dependency) + .doesNotContain("\\-", "└") +assertThat(new File(basedir, "tree.log").getText("UTF-8")).contains("\\- " + dependency) + .doesNotContain("├", "└", "─", "│") + +return true diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java index cbb0af336..46579ded8 100644 --- a/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java +++ b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java @@ -130,11 +130,14 @@ public class TreeMojo extends AbstractMojo { /** * The token set name to use when outputting the dependency tree. Possible values are whitespace, * standard or extended, which use whitespace, standard (ie ASCII) or extended character - * sets respectively. + * sets respectively. When omitted, extended tokens are selected for an interactive console when + * Maven's output-capabilities metadata reports an encoding that supports the tree characters. + * Files, batch mode, missing metadata and incompatible encodings use standard tokens. + * An explicit value always takes precedence over detection. * * @since 2.0-alpha-6 */ - @Parameter(property = "tokens", defaultValue = "standard") + @Parameter(property = "tokens") private String tokens; /** @@ -386,21 +389,7 @@ public DependencyNodeVisitor getSerializingDependencyNodeVisitor(Writer writer) * @return the GraphTokens instance */ private GraphTokens toGraphTokens(String theTokens) { - GraphTokens graphTokens; - - if ("whitespace".equals(theTokens)) { - getLog().debug("+ Using whitespace tree tokens"); - - graphTokens = SerializingDependencyNodeVisitor.WHITESPACE_TOKENS; - } else if ("extended".equals(theTokens)) { - getLog().debug("+ Using extended tree tokens"); - - graphTokens = SerializingDependencyNodeVisitor.EXTENDED_TOKENS; - } else { - graphTokens = SerializingDependencyNodeVisitor.STANDARD_TOKENS; - } - - return graphTokens; + return TreeTokens.select(theTokens, outputFile != null, session, getLog()); } /** diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/TreeTokens.java b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeTokens.java new file mode 100644 index 000000000..ecc513ed5 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeTokens.java @@ -0,0 +1,82 @@ +/* + * 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 org.apache.maven.plugins.dependency.tree; + +import java.nio.charset.Charset; +import java.util.Map; + +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.shared.dependency.graph.traversal.SerializingDependencyNodeVisitor; +import org.apache.maven.shared.dependency.graph.traversal.SerializingDependencyNodeVisitor.GraphTokens; + +/** Selects text tree tokens using Maven's optional execution-request output metadata. */ +final class TreeTokens { + private TreeTokens() {} + + static GraphTokens select(String configured, boolean file, MavenSession session, Log log) { + if (configured != null) { + if ("extended".equals(configured)) { + log.debug("Using explicitly configured extended tree tokens"); + return SerializingDependencyNodeVisitor.EXTENDED_TOKENS; + } + if ("whitespace".equals(configured)) { + log.debug("Using explicitly configured whitespace tree tokens"); + return SerializingDependencyNodeVisitor.WHITESPACE_TOKENS; + } + // Preserve the historical fallback for unrecognized token names, including an empty name. + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } + if (file) { + log.debug("Using standard tree tokens for file output"); + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } + MavenExecutionRequest request = session == null ? null : session.getRequest(); + if (request == null || !request.isInteractiveMode()) { + log.debug("Using standard tree tokens: no interactive Maven request"); + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } + // This string-based contract also works for plugins compiled against older Maven APIs. + Object value = request.getData().get("maven.logging.outputCapabilities"); + if (!(value instanceof Map)) { + log.debug("Using standard tree tokens: Maven output capabilities are unavailable"); + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } + Map capabilities = (Map) value; + if (!"CONSOLE".equals(capabilities.get("destination"))) { + log.debug("Using standard tree tokens: logging destination is not a known console"); + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } + Object name = capabilities.get("encoding"); + if (name instanceof String) { + try { + Charset encoding = Charset.forName((String) name); + if (encoding.canEncode() && encoding.newEncoder().canEncode("\u251c\u2514\u2500\u2502")) { + log.debug("Using extended tree tokens for console encoding " + encoding.name()); + return SerializingDependencyNodeVisitor.EXTENDED_TOKENS; + } + } catch (IllegalArgumentException | UnsupportedOperationException e) { + log.debug("Cannot use Maven's logging encoding: " + e.getMessage()); + } + } + log.debug("Using standard tree tokens: console encoding is unknown or cannot encode tree characters"); + return SerializingDependencyNodeVisitor.STANDARD_TOKENS; + } +} diff --git a/src/site/markdown/examples/tree-mojo.md b/src/site/markdown/examples/tree-mojo.md index fcaa3f690..4ef5f491b 100644 --- a/src/site/markdown/examples/tree-mojo.md +++ b/src/site/markdown/examples/tree-mojo.md @@ -47,6 +47,40 @@ mvn dependency:tree -DoutputType= -DoutputFile= **Note**: Ensure you are using Maven Dependency Plugin version 3.7.0 or later (latest is 3.8.1 as of June 2025) to access these output formats. +## Text tree style + +For text output, `tokens` selects the characters used for the branches: + +- `standard`: ASCII, such as `+-` and `\-`. +- `extended`: box-drawing characters, such as `├─`, `└─` and `│`. +- `whitespace`: indentation without branch characters. + +For example, to force box-drawing characters: + +```shell +mvn dependency:tree -Dtokens=extended +``` + +When `tokens` is omitted, the plugin selects extended characters only for an +interactive console whose reported encoding can represent all the branch +characters. Compatible legacy encodings, such as CP437 and CP850, work as well +as UTF-8. The plugin reads Maven's `maven.logging.outputCapabilities` map from +execution-request data; it does not inspect terminal libraries or infer an +encoding from the operating system or JVM default. + +Automatic selection uses ASCII for `outputFile`, Maven's `-l`/`--log-file`, +batch mode (`-B`), pipes and shell redirection, and missing or unusable output +metadata. Maven versions without the capability map, including unmodified +Maven 3.6.3 and 3.9.16, also use ASCII automatically. The minimum supported +Maven and Java versions are unchanged. Color settings do not control this +selection; the plugin follows the reported destination and encoding. + +An explicit `tokens` value always takes precedence, including for files and +batch mode. Unrecognized values retain the historical ASCII fallback. +The `outputEncoding` parameter controls the plugin's `outputFile` encoding; +it does not change Maven's console or `-l` log encoding. Other output formats +are unaffected by `tokens`. + ## Output Formats # JSON (outputType\=json) diff --git a/src/test/java/org/apache/maven/plugins/dependency/tree/TestTreeMojo.java b/src/test/java/org/apache/maven/plugins/dependency/tree/TestTreeMojo.java index 487a1e847..a236bd554 100644 --- a/src/test/java/org/apache/maven/plugins/dependency/tree/TestTreeMojo.java +++ b/src/test/java/org/apache/maven/plugins/dependency/tree/TestTreeMojo.java @@ -24,13 +24,17 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.io.StringReader; +import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -52,12 +56,16 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import static org.apache.maven.api.plugin.testing.MojoExtension.getVariableValueFromObject; import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -90,6 +98,48 @@ void setUp() { // tests ------------------------------------------------------------------ + @Test + @InjectMojo(goal = "tree") + void usesRequestCapabilitiesForTextTree(TreeMojo mojo) throws Exception { + assertNull(getVariableValueFromObject(mojo, "tokens")); + session.getRequest().setInteractiveMode(true); + Map capabilities = new HashMap<>(); + capabilities.put("destination", "CONSOLE"); + capabilities.put("encoding", "UTF-8"); + session.getRequest().getData().put("maven.logging.outputCapabilities", capabilities); + + DefaultDependencyNode root = + new DefaultDependencyNode(stubFactory.createArtifact("testGroupId", "project", "1.0")); + DefaultDependencyNode child = + new DefaultDependencyNode(stubFactory.createArtifact("testGroupId", "child", "1.0")); + child.setChildren(Collections.emptyList()); + root.setChildren(Collections.singletonList(child)); + StringWriter writer = new StringWriter(); + root.accept(mojo.getSerializingDependencyNodeVisitor(writer)); + + assertTrue(writer.toString().contains("\u2514\u2500"), writer.toString()); + } + + @Test + void nonTextFormatsSkipCapabilityLookup() throws Exception { + MavenSession unusedSession = mock(MavenSession.class); + TreeMojo mojo = new TreeMojo(null, unusedSession, null, null); + String[] formats = {"dot", "graphml", "tgf", "json"}; + Class[] visitors = { + DOTDependencyNodeVisitor.class, + GraphmlDependencyNodeVisitor.class, + TGFDependencyNodeVisitor.class, + JsonDependencyNodeVisitor.class + }; + for (int i = 0; i < formats.length; i++) { + setVariableValueToObject(mojo, "outputType", formats[i]); + assertEquals( + visitors[i], + mojo.getSerializingDependencyNodeVisitor(new StringWriter()).getClass()); + } + verifyNoInteractions(unusedSession); + } + /** * Tests the proper discovery and configuration of the mojo. * diff --git a/src/test/java/org/apache/maven/plugins/dependency/tree/TreeTokensTest.java b/src/test/java/org/apache/maven/plugins/dependency/tree/TreeTokensTest.java new file mode 100644 index 000000000..c6a14c9b9 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/tree/TreeTokensTest.java @@ -0,0 +1,149 @@ +/* + * 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 org.apache.maven.plugins.dependency.tree; + +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.shared.dependency.graph.traversal.SerializingDependencyNodeVisitor; +import org.apache.maven.shared.dependency.graph.traversal.SerializingDependencyNodeVisitor.GraphTokens; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class TreeTokensTest { + @ParameterizedTest + @ValueSource(strings = {"standard", "extended", "whitespace", "", "invalid"}) + void explicitStyleSkipsMetadataAndWinsForFileOutput(String configured) { + MavenSession session = mock(MavenSession.class); + GraphTokens expected = "extended".equals(configured) + ? SerializingDependencyNodeVisitor.EXTENDED_TOKENS + : "whitespace".equals(configured) + ? SerializingDependencyNodeVisitor.WHITESPACE_TOKENS + : SerializingDependencyNodeVisitor.STANDARD_TOKENS; + assertSame(expected, TreeTokens.select(configured, false, session, mock(Log.class))); + assertSame(expected, TreeTokens.select(configured, true, session, mock(Log.class))); + verifyNoInteractions(session); + } + + @Test + void fileOutputSkipsMetadata() { + MavenSession session = mock(MavenSession.class); + assertSame( + SerializingDependencyNodeVisitor.STANDARD_TOKENS, + TreeTokens.select(null, true, session, mock(Log.class))); + verifyNoInteractions(session); + } + + @Test + void batchModeSkipsMetadata() { + MavenExecutionRequest request = spy(new DefaultMavenExecutionRequest().setInteractiveMode(false)); + MavenSession session = mock(MavenSession.class); + when(session.getRequest()).thenReturn(request); + assertSame( + SerializingDependencyNodeVisitor.STANDARD_TOKENS, + TreeTokens.select(null, false, session, mock(Log.class))); + verify(request, never()).getData(); + } + + @Test + void absentSessionOrRequestUsesStandardTokens() { + assertSame( + SerializingDependencyNodeVisitor.STANDARD_TOKENS, + TreeTokens.select(null, false, null, mock(Log.class))); + assertSame( + SerializingDependencyNodeVisitor.STANDARD_TOKENS, + TreeTokens.select(null, false, mock(MavenSession.class), mock(Log.class))); + } + + @ParameterizedTest + @ValueSource(strings = {"UTF-8", "IBM437", "IBM850"}) + void compatibleConsoleEncodingUsesExtendedTokens(String encoding) { + Map capabilities = capabilities("CONSOLE", encoding); + capabilities.put("future-key", "ignored"); + assertSame(SerializingDependencyNodeVisitor.EXTENDED_TOKENS, select(capabilities)); + } + + @ParameterizedTest + @ValueSource(strings = {"US-ASCII", "ISO-8859-1", "windows-1252", "ISO-2022-CN", "", "not a charset", "x-unknown"}) + void unusableConsoleEncodingUsesStandardTokens(String encoding) { + assertSame(SerializingDependencyNodeVisitor.STANDARD_TOKENS, select(capabilities("CONSOLE", encoding))); + } + + @ParameterizedTest + @MethodSource("unusableMetadata") + void missingOrMalformedMetadataUsesStandardTokens(Object metadata) { + assertSame(SerializingDependencyNodeVisitor.STANDARD_TOKENS, select(metadata)); + } + + static Stream unusableMetadata() { + return Stream.of( + null, + "not a map", + 42, + Collections.emptyMap(), + capabilities(null, "UTF-8"), + capabilities(42, "UTF-8"), + capabilities("console", "UTF-8"), + capabilities("FILE", "UTF-8"), + capabilities("REDIRECTED", "UTF-8"), + capabilities("UNKNOWN", "UTF-8"), + capabilities("FUTURE_DESTINATION", "UTF-8"), + capabilities("CONSOLE", null), + capabilities("CONSOLE", 42), + capabilities("CONSOLE", Charset.forName("UTF-8"))); + } + + private static Map capabilities(Object destination, Object encoding) { + Map capabilities = new HashMap<>(); + if (destination != null) { + capabilities.put("destination", destination); + } + if (encoding != null) { + capabilities.put("encoding", encoding); + } + return capabilities; + } + + private static GraphTokens select(Object metadata) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest().setInteractiveMode(true); + if (metadata != null) { + request.getData().put("maven.logging.outputCapabilities", metadata); + } + MavenSession session = mock(MavenSession.class); + when(session.getRequest()).thenReturn(request); + return TreeTokens.select(null, false, session, mock(Log.class)); + } +}