/* Copyright 2013 Jonatan Jönsson
*
* Licensed 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 se.softhouse.jargo;
import static java.util.Arrays.asList;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.Fail.failure;
import static org.junit.Assert.fail;
import static se.softhouse.common.strings.StringsUtil.NEWLINE;
import static se.softhouse.jargo.Arguments.integerArgument;
import static se.softhouse.jargo.Arguments.optionArgument;
import static se.softhouse.jargo.Arguments.stringArgument;
import static se.softhouse.jargo.CommandLineParser.withArguments;
import static se.softhouse.jargo.Constants.EXPECTED_TEST_TIME_FOR_THIS_SUITE;
import static se.softhouse.jargo.utils.Assertions2.assertThat;
import java.io.File;
import java.io.IOException;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.junit.Test;
import se.softhouse.common.testlib.Explanation;
import se.softhouse.jargo.ArgumentExceptions.UnexpectedArgumentException;
import se.softhouse.jargo.commands.Build;
import se.softhouse.jargo.commands.Build.BuildTarget;
import se.softhouse.jargo.commands.Clean;
import se.softhouse.jargo.internal.Texts.ProgrammaticErrors;
import se.softhouse.jargo.internal.Texts.UsageTexts;
import se.softhouse.jargo.internal.Texts.UserErrors;
import se.softhouse.jargo.utils.ArgumentExpector;
import se.softhouse.jargo.utils.Assertions2.UsageAssert;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.io.Files;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* Tests for {@link CommandLineParser}
*/
public class CommandLineParserTest
{
/**
* An example of how to create a <b>easy to understand</b> command line invocation:<br>
* java testprog --enable-logging --listen-port 8090 Hello
*/
@Test
public void testMixedArgumentTypes() throws ArgumentException
{
Argument<Boolean> enableLogging = optionArgument("-l", "--enable-logging").description("Output debug information to standard out").build();
Argument<Integer> port = integerArgument("-p", "--listen-port").defaultValue(8080).description("The port to start the server on.").build();
Argument<String> greetingPhrase = stringArgument().description("A greeting phrase to greet new connections with").build();
ArgumentExpector expector = new ArgumentExpector();
expector.expectThat(enableLogging).receives(true).given("--enable-logging");
expector.expectThat(port).receives(8090).given("--listen-port 8090");
expector.expectThat(greetingPhrase).receives("Hello").given("Hello");
}
@Test
public void testShorthandInvocation() throws ArgumentException
{
// For a single argument it's easier to call parse directly on the ArgumentBuilder
assertThat(integerArgument("-n").parse("-n", "42")).isEqualTo(42);
}
@Test(expected = UnexpectedArgumentException.class)
public void testUnhandledParameter() throws ArgumentException
{
CommandLineParser.withArguments().parse("Unhandled");
}
@Test
public void testMissingParameterForArgument()
{
Argument<Integer> numbers = integerArgument("--number", "-n").build();
try
{
numbers.parse("-n");
fail("-n parameter should be missing a parameter");
}
catch(ArgumentException expected)
{
// As -n was given on the command line that is the one that should appear in the
// exception message
assertThat(expected).hasMessage(String.format(UserErrors.MISSING_PARAMETER, "<integer>", "-n"));
}
}
@Test
public void testThatEmptyMetaDescriptionsAreForbidden()
{
try
{
integerArgument("-n").metaDescription("");
fail("empty metadescriptions must be forbidden as it wouldn't be possible for users to realize that an argument accepts a parameter");
}
catch(IllegalArgumentException expected)
{
assertThat(expected).hasMessage(ProgrammaticErrors.INVALID_META_DESCRIPTION);
}
}
@Test(expected = ArgumentException.class)
public void testWrongArgumentForShorthandInvocation() throws ArgumentException
{
integerArgument().parse("a42");
}
@Test
public void testThatCloseMatchIsSuggestedForTypos()
{
try
{
integerArgument("-n", "--number").parse("-number");
fail("-number should have to be --number");
}
catch(ArgumentException expected)
{
assertThat(expected).hasMessage(String.format(UserErrors.SUGGESTION, "-number", "--number"));
}
}
@Test
public void testThatAlreadyParsedArgumentsAreNotSuggested()
{
try
{
integerArgument("-n", "--number").parse("--number", "1", "-number");
fail("-number should have to be --number");
}
catch(ArgumentException expected)
{
assertThat(expected).hasMessage("Unexpected argument: -number, previous argument: 1");
}
}
@Test
public void testIterableInterface() throws ArgumentException
{
String[] args = {"--number", "1"};
Argument<Integer> number = integerArgument("--number").build();
ParsedArguments listResult = CommandLineParser.withArguments(Arrays.<Argument<?>>asList(number)).parse(Arrays.asList(args));
ParsedArguments arrayResult = CommandLineParser.withArguments(number).parse(args);
assertThat(listResult).isEqualTo(arrayResult);
}
@Test
public void testEqualsAndHashcodeForParsedArguments() throws ArgumentException
{
Argument<Integer> number = integerArgument("--number").build();
CommandLineParser parser = CommandLineParser.withArguments(number);
ParsedArguments parsedArguments = parser.parse();
assertThat(parsedArguments).isNotEqualTo(null);
assertThat(parsedArguments).isEqualTo(parsedArguments);
ParsedArguments parsedArgumentsTwo = parser.parse();
assertThat(parsedArguments.hashCode()).isEqualTo(parsedArgumentsTwo.hashCode());
assertThat(withArguments().parse()).isEqualTo(withArguments().parse());
}
@Test
@SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED", justification = Explanation.FAIL_FAST)
public void testThatNameCollisionAmongTwoDifferentArgumentsIsDetected()
{
Argument<Integer> numberOne = integerArgument("-n", "-s").build();
Argument<Integer> numberTwo = integerArgument("-t", "-s").build();
try
{
CommandLineParser.withArguments(numberOne, numberTwo);
fail("Duplicate -s name not detected");
}
catch(IllegalArgumentException expected)
{
assertThat(expected).hasMessage(String.format(ProgrammaticErrors.NAME_COLLISION, "-s"));
}
}
@Test
@SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED", justification = Explanation.FAIL_FAST)
public void testErrorHandlingForTwoIndexedParametersWithTheSameDefinition()
{
Argument<Integer> numberOne = integerArgument().build();
// There wouldn't be a way to know which argument numberOne referenced
try
{
CommandLineParser.withArguments(numberOne, numberOne);
fail("duplicate argument defintions not detected");
}
catch(IllegalArgumentException expected)
{
assertThat(expected).hasMessage(String.format(ProgrammaticErrors.UNIQUE_ARGUMENT, "<integer>"));
}
}
@Test
public void testThatArgumentNotGivenIsIllegalArgument() throws ArgumentException
{
Argument<Integer> numberOne = integerArgument().build();
Argument<Integer> numberTwo = integerArgument().build();
try
{
CommandLineParser.withArguments(numberOne).parse().get(numberTwo);
fail("numberTwo should be illegal as only numberOne is handled");
}
catch(IllegalArgumentException e)
{
assertThat(e).hasMessage(String.format(ProgrammaticErrors.ILLEGAL_ARGUMENT, numberTwo));
}
}
@Test
public void testThatItsIllegalToPassNullInArguments() throws ArgumentException
{
try
{
integerArgument().parse(null, null);
}
catch(NullPointerException expected)
{
assertThat(expected).hasMessage("Argument strings may not be null (discovered one at index 0)");
}
}
@Test
public void testThatInputIsCopiedBeforeBeingWorkedOn() throws ArgumentException
{
List<String> args = Arrays.asList("-Dfoo=bar", "-Dbaz=zoo");
List<String> stateBeforeParsing = Lists.newArrayList(args);
Argument<Map<String, String>> arg = stringArgument("-D").asPropertyMap().build();
CommandLineParser.withArguments(arg).parse(args);
assertThat(args).isEqualTo(stateBeforeParsing);
}
@Test
public void testThatQuotesAreNotTrimmedAsTheShellIsResponsibleForThat() throws ArgumentException
{
assertThat(stringArgument().parse("\"hello\"")).isEqualTo("\"hello\"");
}
@Test
public void testReadingArgumentsFromFile() throws IOException, ArgumentException
{
File tempFile = File.createTempFile("_testReadingArgumentsFromFile", ".arguments");
tempFile.deleteOnExit();
Files.write(Joiner.on(NEWLINE).join("lo", "wor"), tempFile, Charsets.UTF_8);
List<String> parsed = stringArgument().variableArity().parse("hel", "@" + tempFile.getPath(), "ld");
assertThat(parsed).isEqualTo(Arrays.asList("hel", "lo", "wor", "ld"));
}
/**
* <pre>
* So given referencedFile that contains:
* world
* and another file that contains:
* @referencedFile
* the invocation "hello @referencedFile" shall turn into "hello world"
* </pre>
*/
@Test
public void testThatFilesCanReferenceFilesWhenReadingArgumentsFromFile() throws IOException, ArgumentException
{
File referenceFile = File.createTempFile("_testReadingFileReferencedFromFile", ".referencedFile");
referenceFile.deleteOnExit();
Files.write("world", referenceFile, Charsets.UTF_8);
File referencingFile = File.createTempFile("_testReadingFileReferencedFromFile", ".referencingFile");
referencingFile.deleteOnExit();
String referencedFilename = "@" + referenceFile.getPath();
Files.write(Joiner.on(NEWLINE).join("hello", referencedFilename, "and"), referencingFile, Charsets.UTF_8);
List<String> parsed = stringArgument().variableArity().parse("@" + referencingFile.getPath(), "foo");
assertThat(parsed).isEqualTo(Arrays.asList("hello", "world", "and", "foo"));
}
@Test
public void testThatReadingFromUnreadableFileThrowsArgumentException() throws IOException, ArgumentException
{
File tempFile = File.createTempFile("_testReadingArgumentsFromFile", ".arguments");
tempFile.deleteOnExit();
tempFile.setReadable(false);
try
{
stringArgument().parse("@" + tempFile.getPath());
fail("Reading from an unreadable file should trigger an exception");
}
catch(ArgumentException expected)
{
assertThat(expected).hasMessage("Failed while reading arguments from: " + tempFile.getPath());
}
}
@Test
public void testThatFileReferenceToOrdinaryArgumentIsTreatedAsARegularArgument()
{
assertThat(stringArgument().parse("@non-existing-file")).isEqualTo("@non-existing-file");
}
@Test
public void testThatAllArgumentsAreTreatedAsIndexedArgumentsAfterEndOfOptions() throws Exception
{
Argument<String> optionOne = stringArgument("--option").build();
Argument<String> optionTwo = stringArgument("--option-two").defaultValue("two").build();
Argument<List<String>> indexed = stringArgument().variableArity().build();
CommandLineParser parser = CommandLineParser.withArguments(optionOne, optionTwo, indexed);
ParsedArguments result = parser.parse("--option", "one", "--", "--option-two", "--option", "--");
assertThat(result.get(indexed)).isEqualTo(asList("--option-two", "--option", "--"));
assertThat(result.get(optionOne)).isEqualTo("one");
assertThat(result.get(optionTwo)).isEqualTo("two");
}
@Test
public void testThatInvalidArgumentsAddedLaterOnDoesNotWreckTheExistingParser() throws Exception
{
Argument<Integer> number = integerArgument("-n").build();
Argument<Integer> numberTwo = integerArgument("-N").build();
CommandLineParser parser = CommandLineParser.withArguments(number);
try
{
// Oops meant to add numberTwo
parser.andArguments(number);
fail("number should be handled already by parser, adding it again should fail");
}
catch(IllegalArgumentException expected)
{
// Verify that adding number didn't leave the parser in a weird state by adding the
// correct argument and parsing it
parser.andArguments(numberTwo);
assertThat(parser.parse("-N", "2").get(numberTwo)).isEqualTo(2);
}
}
@Test
public void testThatInvalidCommandsAddedLaterOnDoesNotWreckTheExistingParser() throws Exception
{
BuildTarget target = new BuildTarget();
CommandLineParser parser = CommandLineParser.withCommands(new Clean(target));
try
{
// Oops meant to add new Build
parser.andCommands(new Clean(target));
fail("clean should be handled already by parser, adding it again should fail");
}
catch(IllegalArgumentException expected)
{
// Verify that adding Clean didn't leave the parser in a weird state by adding
// the correct argument and parsing it
parser.andCommands(new Build(target));
parser.parse("clean", "build");
}
}
@Test
public void testThatParserIsModifiableAfterFailedProgramDescriptionModification() throws Exception
{
testThatNullDoesNotCauseOtherConcurrentUpdatesToFail(new ParserInvocation<String>(){
@Override
public void invoke(CommandLineParser parser, String value)
{
parser.programDescription(value);
}
}, "42").contains("42");
}
@Test
public void testThatParserIsModifiableAfterFailedProgramNameModification() throws Exception
{
testThatNullDoesNotCauseOtherConcurrentUpdatesToFail(new ParserInvocation<String>(){
@Override
public void invoke(CommandLineParser parser, String value)
{
parser.programName(value);
}
}, "42").contains("42");
}
@Test
public void testThatParserIsModifiableAfterFailedAddArgumentOperation() throws Exception
{
testThatNullDoesNotCauseOtherConcurrentUpdatesToFail(new ParserInvocation<Argument<Integer>>(){
@Override
public void invoke(CommandLineParser parser, Argument<Integer> value)
{
parser.andArguments(value);
}
}, integerArgument("-n").build()).contains("-n");
}
private <T> UsageAssert testThatNullDoesNotCauseOtherConcurrentUpdatesToFail(final ParserInvocation<T> toInvoke, final @Nonnull T nonnullValue)
throws InterruptedException
{
final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();
final CommandLineParser parser = CommandLineParser.withArguments();
Thread otherThread = new Thread(){
@Override
public void run()
{
toInvoke.invoke(parser, nonnullValue);
}
};
otherThread.setUncaughtExceptionHandler(new UncaughtExceptionHandler(){
@Override
public void uncaughtException(Thread t, Throwable e)
{
failure.set(e);
}
});
try
{
// Oops meant to use nonnullValue
toInvoke.invoke(parser, null);
throw failure("null parameter should cause NPE");
}
catch(NullPointerException expected)
{
// Verify that adding null didn't leave the parser in a weird state by setting the
// correct value and using it
otherThread.start();
otherThread.join(EXPECTED_TEST_TIME_FOR_THIS_SUITE);
assertThat(otherThread.isAlive()).as("otherThread took more time than what is expected for the whole test suite").isFalse();
assertThat(failure.get()).as("Failure from otherThread" + failure.get()).isNull();
return assertThat(parser.usage());
}
}
private interface ParserInvocation<T>
{
/**
* Method that will be called twice, the first time with {@code value} as <code>null</code>
* and the next time with a proper value
*/
void invoke(CommandLineParser parser, @Nullable T value);
}
@Test
public void testThatParsedArgumentsCanBeInHashSet() throws Exception
{
Argument<Integer> number = integerArgument().build();
CommandLineParser parser = CommandLineParser.withArguments(number);
Set<ParsedArguments> parsedResults = Sets.newLinkedHashSet();
ParsedArguments firstResult = parser.parse("1");
parsedResults.add(firstResult);
ParsedArguments secondResult = parser.parse("2");
parsedResults.add(secondResult);
assertThat(parsedResults).contains(firstResult, secondResult);
assertThat(firstResult.hashCode()).as("Hashcode for different ParsedArguments should differ").isNotEqualTo(secondResult.hashCode());
}
@Test
public void testThatEmptyNamesAreAllowed() throws Exception
{
assertThat(integerArgument("").parse("", "1")).isEqualTo(1);
}
@Test
public void testAddingArgumentsInChainedFashion() throws ArgumentException
{
Argument<Integer> numberOne = integerArgument("-n").build();
Argument<Integer> numberTwo = integerArgument("-n2").build();
ParsedArguments result = CommandLineParser.withArguments().andArguments(numberOne).andArguments(numberTwo).parse("-n", "1", "-n2", "2");
assertThat(result.get(numberOne)).isEqualTo(1);
assertThat(result.get(numberTwo)).isEqualTo(2);
}
@Test
public void testThatArgumentBuilderTransfersPropertiesWhenBuilderIsChanged() throws Exception
{
Argument<List<Integer>> n = integerArgument().description("a description").ignoreCase().required().names("-n").separator("/")
.metaDescription("<foo>") //
.arity(2)// Changes builder
.build();
ParsedArguments result = CommandLineParser.withArguments(n).parse("-N/1", "2");
assertThat(result.get(n)).isEqualTo(Arrays.asList(1, 2));
assertThat(n.usage()).contains("-n/<foo>").contains(UsageTexts.REQUIRED).contains("a description");
assertThat(integerArgument("hidden-argument").hideFromUsage().arity(2).usage()).doesNotContain("hidden-argument");
}
@Test
public void testThatNamesAreNotAllowedToHaveSpacesInThem() throws Exception
{
try
{
integerArgument("foo bar");
fail("a space should not be allowed in argument names as it would be quirky to trigger such an argument from the command line");
}
catch(IllegalArgumentException expected)
{
assertThat(expected).hasMessage("Detected a space in foo bar, argument names must not have spaces in them");
}
}
}