Skip to content

Commit a147d9a

Browse files
author
Joseph Cooper
committed
Refactor CLI argument parsing
Closes facebook#465
1 parent c84f02f commit a147d9a

File tree

3 files changed

+62
-101
lines changed

3 files changed

+62
-101
lines changed

Diff for: core/src/main/java/com/facebook/ktfmt/cli/Main.kt

+1-4
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,7 @@ class Main(
7272

7373

7474
fun run(): Int {
75-
val parsedArgs = when (val argParseResult = ParsedArgs.processArgs(err, args)) {
76-
is ArgParseResult.Ok -> argParseResult.parsedArgs
77-
is ArgParseResult.Error -> exitFatal(argParseResult.errorMessage, 1)
78-
}
75+
val parsedArgs = ParsedArgs.processArgs(args).getOrElse { exitFatal(it.errorMessage, 1) }
7976
if (parsedArgs.fileNames.isEmpty()) {
8077
err.println(
8178
"Usage: ktfmt [--dropbox-style | --google-style | --kotlinlang-style] [--dry-run] [--set-exit-if-changed] [--stdin-name=<name>] [--do-not-remove-unused-imports] File1.kt File2.kt ...")

Diff for: core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt

+23-17
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package com.facebook.ktfmt.cli
1919
import com.facebook.ktfmt.format.Formatter
2020
import com.facebook.ktfmt.format.FormattingOptions
2121
import java.io.File
22-
import java.io.PrintStream
2322
import java.nio.charset.StandardCharsets.UTF_8
2423

2524
/** ParsedArgs holds the arguments passed to ktfmt on the command-line, after parsing. */
@@ -39,16 +38,16 @@ data class ParsedArgs(
3938
) {
4039
companion object {
4140

42-
fun processArgs(err: PrintStream, args: Array<String>): ArgParseResult {
41+
fun processArgs(args: Array<String>): ParseResult<ParsedArgs> {
4342
if (args.size == 1 && args[0].startsWith("@")) {
44-
return ArgParseResult.Ok(parseOptions(err, File(args[0].substring(1)).readLines(UTF_8).toTypedArray()))
43+
return parseOptions(File(args[0].substring(1)).readLines(UTF_8).toTypedArray())
4544
} else {
46-
return ArgParseResult.Ok(parseOptions(err, args))
45+
return parseOptions(args)
4746
}
4847
}
4948

5049
/** parseOptions parses command-line arguments passed to ktfmt. */
51-
fun parseOptions(err: PrintStream, args: Array<String>): ParsedArgs {
50+
fun parseOptions(args: Array<out String>): ParseResult<ParsedArgs> {
5251
val fileNames = mutableListOf<String>()
5352
var formattingOptions = FormattingOptions()
5453
var dryRun = false
@@ -64,34 +63,41 @@ data class ParsedArgs(
6463
arg == "--dry-run" || arg == "-n" -> dryRun = true
6564
arg == "--set-exit-if-changed" -> setExitIfChanged = true
6665
arg == "--do-not-remove-unused-imports" -> removeUnusedImports = false
67-
arg.startsWith("--stdin-name") -> stdinName = parseKeyValueArg(err, "--stdin-name", arg)
68-
arg.startsWith("--") -> err.println("Unexpected option: $arg")
69-
arg.startsWith("@") -> err.println("Unexpected option: $arg")
66+
arg.startsWith("--stdin-name=") ->
67+
stdinName = parseKeyValueArg("--stdin-name", arg).getOrElse { error -> return error }
68+
arg.startsWith("--") -> return ParseResult.Error("Unexpected option: $arg")
69+
arg.startsWith("@") -> return ParseResult.Error("Unexpected option: $arg")
7070
else -> fileNames.add(arg)
7171
}
7272
}
7373

74-
return ParsedArgs(
74+
return ParseResult.Ok(ParsedArgs(
7575
fileNames,
7676
formattingOptions.copy(removeUnusedImports = removeUnusedImports),
7777
dryRun,
7878
setExitIfChanged,
7979
stdinName,
80-
)
80+
))
8181
}
8282

83-
private fun parseKeyValueArg(err: PrintStream, key: String, arg: String): String? {
83+
private fun parseKeyValueArg(key: String, arg: String): ParseResult<String> {
8484
val parts = arg.split('=', limit = 2)
8585
if (parts[0] != key || parts.size != 2) {
86-
err.println("Found option '${arg}', expected '${key}=<value>'")
87-
return null
86+
return ParseResult.Error("Found option '${arg}', expected '${key}=<value>'")
87+
8888
}
89-
return parts[1]
89+
return ParseResult.Ok(parts[1])
9090
}
9191
}
9292
}
9393

94-
sealed interface ArgParseResult {
95-
data class Ok(val parsedArgs: ParsedArgs): ArgParseResult
96-
data class Error(val errorMessage: String): ArgParseResult
94+
sealed interface ParseResult<out V: Any> {
95+
data class Ok<V: Any>(val parsedValue: V): ParseResult<V>
96+
data class Error(val errorMessage: String): ParseResult<Nothing>
9797
}
98+
99+
inline fun <V: Any> ParseResult<V>.getOrElse(onError: (ParseResult.Error) -> V): V =
100+
when (this) {
101+
is ParseResult.Ok<V> -> this.parsedValue
102+
is ParseResult.Error-> onError(this)
103+
}

Diff for: core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt

+38-80
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,13 @@ package com.facebook.ktfmt.cli
1919
import com.facebook.ktfmt.format.Formatter
2020
import com.facebook.ktfmt.format.FormattingOptions
2121
import com.google.common.truth.Truth.assertThat
22-
import java.io.ByteArrayOutputStream
23-
import java.io.FileNotFoundException
24-
import java.io.PrintStream
25-
import kotlin.io.path.createTempDirectory
26-
import kotlin.test.assertFailsWith
2722
import org.junit.After
2823
import org.junit.Test
2924
import org.junit.runner.RunWith
3025
import org.junit.runners.JUnit4
26+
import java.io.FileNotFoundException
27+
import kotlin.io.path.createTempDirectory
28+
import kotlin.test.assertFailsWith
3129

3230
@Suppress("FunctionNaming")
3331
@RunWith(JUnit4::class)
@@ -41,155 +39,115 @@ class ParsedArgsTest {
4139
}
4240

4341
@Test
44-
fun `files to format are returned and unknown flags are reported`() {
45-
val (parsed, out) = parseTestOptions("foo.kt", "--unknown")
46-
47-
assertThat(parsed.fileNames).containsExactly("foo.kt")
48-
assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n")
42+
fun `unknown flags return an error`() {
43+
val result = ParsedArgs.parseOptions(arrayOf("--unknown"))
44+
assertThat(result).isInstanceOf(ParseResult.Error::class.java)
4945
}
5046

5147
@Test
52-
fun `files to format are returned and flags starting with @ are reported`() {
53-
val (parsed, out) = parseTestOptions("foo.kt", "@unknown")
54-
55-
assertThat(parsed.fileNames).containsExactly("foo.kt")
56-
assertThat(out.toString()).isEqualTo("Unexpected option: @unknown\n")
48+
fun `unknown flags starting with '@' return an error`() {
49+
val result = ParsedArgs.parseOptions(arrayOf("@unknown"))
50+
assertThat(result).isInstanceOf(ParseResult.Error::class.java)
5751
}
5852

5953
@Test
6054
fun `parseOptions uses default values when args are empty`() {
61-
val (parsed, _) = parseTestOptions("foo.kt")
55+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("foo.kt")))
6256

6357
val formattingOptions = parsed.formattingOptions
64-
assertThat(formattingOptions.style).isEqualTo(FormattingOptions.Style.FACEBOOK)
65-
assertThat(formattingOptions.maxWidth).isEqualTo(100)
66-
assertThat(formattingOptions.blockIndent).isEqualTo(2)
67-
assertThat(formattingOptions.continuationIndent).isEqualTo(4)
68-
assertThat(formattingOptions.removeUnusedImports).isTrue()
69-
assertThat(formattingOptions.debuggingPrintOpsAfterFormatting).isFalse()
7058

71-
assertThat(parsed.dryRun).isFalse()
72-
assertThat(parsed.setExitIfChanged).isFalse()
73-
assertThat(parsed.stdinName).isNull()
59+
val defaultFormattingOptions = FormattingOptions()
60+
assertThat(formattingOptions).isEqualTo(defaultFormattingOptions)
7461
}
7562

7663
@Test
77-
fun `parseOptions recognizes --dropbox-style and rejects unknown flags`() {
78-
val (parsed, out) = parseTestOptions("--dropbox-style", "foo.kt", "--unknown")
79-
80-
assertThat(parsed.fileNames).containsExactly("foo.kt")
81-
assertThat(parsed.formattingOptions.blockIndent).isEqualTo(4)
82-
assertThat(parsed.formattingOptions.continuationIndent).isEqualTo(4)
83-
assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n")
64+
fun `parseOptions recognizes --dropbox-style`() {
65+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--dropbox-style", "foo.kt")))
66+
assertThat(parsed.formattingOptions).isEqualTo(Formatter.DROPBOX_FORMAT)
8467
}
8568

8669
@Test
8770
fun `parseOptions recognizes --google-style`() {
88-
val (parsed, _) = parseTestOptions("--google-style", "foo.kt")
71+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--google-style", "foo.kt")))
8972
assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT)
9073
}
9174

9275
@Test
9376
fun `parseOptions recognizes --dry-run`() {
94-
val (parsed, _) = parseTestOptions("--dry-run", "foo.kt")
77+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--dry-run", "foo.kt")))
9578
assertThat(parsed.dryRun).isTrue()
9679
}
9780

9881
@Test
9982
fun `parseOptions recognizes -n as --dry-run`() {
100-
val (parsed, _) = parseTestOptions("-n", "foo.kt")
83+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("-n", "foo.kt")))
10184
assertThat(parsed.dryRun).isTrue()
10285
}
10386

10487
@Test
10588
fun `parseOptions recognizes --set-exit-if-changed`() {
106-
val (parsed, _) = parseTestOptions("--set-exit-if-changed", "foo.kt")
89+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--set-exit-if-changed", "foo.kt")))
10790
assertThat(parsed.setExitIfChanged).isTrue()
10891
}
10992

11093
@Test
11194
fun `parseOptions defaults to removing imports`() {
112-
val (parsed, _) = parseTestOptions("foo.kt")
95+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("foo.kt")))
11396
assertThat(parsed.formattingOptions.removeUnusedImports).isTrue()
11497
}
11598

11699
@Test
117100
fun `parseOptions recognizes --do-not-remove-unused-imports to removing imports`() {
118-
val (parsed, _) = parseTestOptions("--do-not-remove-unused-imports", "foo.kt")
101+
val parsed =
102+
assertSucceeds(ParsedArgs.parseOptions(arrayOf("--do-not-remove-unused-imports", "foo.kt")))
119103
assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
120104
}
121105

122106
@Test
123-
fun `parseOptions handles dropbox style and --do-not-remove-unused-imports`() {
124-
val (parsed, _) =
125-
parseTestOptions("--do-not-remove-unused-imports", "--dropbox-style", "foo.kt")
126-
assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
127-
assertThat(parsed.formattingOptions.style).isEqualTo(FormattingOptions.Style.DROPBOX)
128-
}
129-
130-
@Test
131-
fun `parseOptions handles google style and --do-not-remove-unused-imports`() {
132-
val (parsed, _) = parseTestOptions("--do-not-remove-unused-imports", "--google-style", "foo.kt")
133-
assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
134-
assertThat(parsed.formattingOptions.style).isEqualTo(FormattingOptions.Style.GOOGLE)
135-
}
136-
137-
@Test
138-
fun `parseOptions --stdin-name`() {
139-
val (parsed, _) = parseTestOptions("--stdin-name=my/foo.kt")
107+
fun `parseOptions recognizes --stdin-name`() {
108+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--stdin-name=my/foo.kt")))
140109
assertThat(parsed.stdinName).isEqualTo("my/foo.kt")
141110
}
142111

143112
@Test
144-
fun `parseOptions --stdin-name with empty value`() {
145-
val (parsed, _) = parseTestOptions("--stdin-name=")
113+
fun `parseOptions accepts --stdin-name with empty value`() {
114+
val parsed = assertSucceeds(ParsedArgs.parseOptions(arrayOf("--stdin-name=")))
146115
assertThat(parsed.stdinName).isEqualTo("")
147116
}
148117

149-
@Test
150-
fun `parseOptions --stdin-name without value`() {
151-
val (parsed, out) = parseTestOptions("--stdin-name")
152-
assertThat(out).isEqualTo("Found option '--stdin-name', expected '--stdin-name=<value>'\n")
153-
assertThat(parsed.stdinName).isNull()
154-
}
155-
156-
@Test
157-
fun `parseOptions --stdin-name prefix`() {
158-
val (parsed, out) = parseTestOptions("--stdin-namea")
159-
assertThat(out).isEqualTo("Found option '--stdin-namea', expected '--stdin-name=<value>'\n")
160-
assertThat(parsed.stdinName).isNull()
161-
}
118+
@Test
119+
fun `parseOptions --stdin-name without value`() {
120+
val parseResult = ParsedArgs.parseOptions(arrayOf("--stdin-name"))
121+
assertThat(parseResult).isInstanceOf(ParseResult.Error::class.java)
122+
}
162123

163124
@Test
164125
fun `processArgs use the @file option with non existing file`() {
165-
val out = ByteArrayOutputStream()
166-
167126
val e =
168127
assertFailsWith<FileNotFoundException> {
169-
ParsedArgs.processArgs(PrintStream(out), arrayOf("@non-existing-file"))
128+
ParsedArgs.processArgs(arrayOf("@non-existing-file"))
170129
}
171130
assertThat(e.message).contains("non-existing-file (No such file or directory)")
172131
}
173132

174133
@Test
175134
fun `processArgs use the @file option with file containing arguments`() {
176-
val out = ByteArrayOutputStream()
177135
val file = root.resolve("existing-file")
178136
file.writeText("--google-style\n--dry-run\n--set-exit-if-changed\nFile1.kt\nFile2.kt\n")
179137

180-
val result = ParsedArgs.processArgs(PrintStream(out), arrayOf("@" + file.absolutePath))
181-
assertThat(result).isInstanceOf(ArgParseResult.Ok::class.java)
138+
val result = ParsedArgs.processArgs(arrayOf("@" + file.absolutePath))
139+
assertThat(result).isInstanceOf(ParseResult.Ok::class.java)
182140

183-
val parsed = (result as ArgParseResult.Ok).parsedArgs
141+
val parsed = (result as ParseResult.Ok).parsedValue
184142

185143
assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT)
186144
assertThat(parsed.dryRun).isTrue()
187145
assertThat(parsed.setExitIfChanged).isTrue()
188146
assertThat(parsed.fileNames).containsExactlyElementsIn(listOf("File1.kt", "File2.kt"))
189147
}
190148

191-
private fun parseTestOptions(vararg args: String): Pair<ParsedArgs, String> {
192-
val out = ByteArrayOutputStream()
193-
return Pair(ParsedArgs.parseOptions(PrintStream(out), arrayOf(*args)), out.toString())
149+
private fun <V : Any> assertSucceeds(parseResult: ParseResult<V>): V {
150+
assertThat(parseResult).isInstanceOf(ParseResult.Ok::class.java)
151+
return (parseResult as ParseResult.Ok<V>).parsedValue
194152
}
195153
}

0 commit comments

Comments
 (0)