diff options
author | Jordan Demeulenaere <jdemeulenaere@google.com> | 2023-02-24 00:31:02 +0000 |
---|---|---|
committer | Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> | 2023-02-24 00:31:02 +0000 |
commit | 457d4958db1c37b1537db867f4d84faf76118d67 (patch) | |
tree | 7cdf574cfeb4a3392972eb66ac363ecb10ec9251 | |
parent | 1d2450dca6bb80f1b7a60d961056092b238ed61b (diff) | |
parent | b7dd8bffc64d3da128976c2f01e91f5e88053374 (diff) | |
download | ktfmt-457d4958db1c37b1537db867f4d84faf76118d67.tar.gz |
Revert "Revert "Merge tag 'v0.42' into update"" am: 582c58a7a2 am: 8c6e055aaf am: f2ef27b6a0 am: b7dd8bffc6
Original change: https://android-review.googlesource.com/c/platform/external/ktfmt/+/2453025
Change-Id: Id4eb2812c770e64d1259a104f2c205f23694e45b
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
28 files changed, 9244 insertions, 991 deletions
diff --git a/core/pom.xml b/core/pom.xml index c7e8df4..765c8f1 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -11,7 +11,7 @@ <parent> <groupId>com.facebook</groupId> <artifactId>ktfmt-parent</artifactId> - <version>0.39</version> + <version>0.42</version> </parent> <properties> diff --git a/core/src/main/java/com/facebook/ktfmt/cli/Main.kt b/core/src/main/java/com/facebook/ktfmt/cli/Main.kt index 4c965bb..0a08944 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/Main.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/Main.kt @@ -70,7 +70,7 @@ class Main( fun run(): Int { if (parsedArgs.fileNames.isEmpty()) { err.println( - "Usage: ktfmt [--dropbox-style | --google-style | --kotlinlang-style] [--dry-run] [--set-exit-if-changed] File1.kt File2.kt ...") + "Usage: ktfmt [--dropbox-style | --google-style | --kotlinlang-style] [--dry-run] [--set-exit-if-changed] [--stdin-name=<name>] File1.kt File2.kt ...") err.println("Or: ktfmt @file") return 1 } @@ -82,6 +82,9 @@ class Main( } catch (e: Exception) { 1 } + } else if (parsedArgs.stdinName != null) { + err.println("Error: --stdin-name can only be used with stdin") + return 1 } val files: List<File> @@ -120,7 +123,7 @@ class Main( * @return true iff input is valid and already formatted. */ private fun format(file: File?): Boolean { - val fileName = file?.toString() ?: "<stdin>" + val fileName = file?.toString() ?: parsedArgs.stdinName ?: "<stdin>" try { val code = file?.readText() ?: BufferedReader(InputStreamReader(input)).readText() val formattedCode = Formatter.format(parsedArgs.formattingOptions, code) @@ -130,7 +133,7 @@ class Main( if (file == null) { if (parsedArgs.dryRun) { if (!alreadyFormatted) { - out.println("<stdin>") + out.println(fileName) } } else { out.print(formattedCode) diff --git a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt index 503cb89..4c66efd 100644 --- a/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt +++ b/core/src/main/java/com/facebook/ktfmt/cli/ParsedArgs.kt @@ -33,6 +33,8 @@ data class ParsedArgs( /** Return exit code 1 if any formatting changes are detected. */ val setExitIfChanged: Boolean, + /** File name to report when formating code from stdin */ + val stdinName: String?, ) { companion object { @@ -50,6 +52,7 @@ data class ParsedArgs( var formattingOptions = FormattingOptions() var dryRun = false var setExitIfChanged = false + var stdinName: String? = null for (arg in args) { when { @@ -58,12 +61,23 @@ data class ParsedArgs( arg == "--kotlinlang-style" -> formattingOptions = Formatter.KOTLINLANG_FORMAT arg == "--dry-run" || arg == "-n" -> dryRun = true arg == "--set-exit-if-changed" -> setExitIfChanged = true + arg.startsWith("--stdin-name") -> stdinName = parseKeyValueArg(err, "--stdin-name", arg) arg.startsWith("--") -> err.println("Unexpected option: $arg") arg.startsWith("@") -> err.println("Unexpected option: $arg") else -> fileNames.add(arg) } } - return ParsedArgs(fileNames, formattingOptions, dryRun, setExitIfChanged) + + return ParsedArgs(fileNames, formattingOptions, dryRun, setExitIfChanged, stdinName) + } + + private fun parseKeyValueArg(err: PrintStream, key: String, arg: String): String? { + val parts = arg.split('=', limit = 2) + if (parts[0] != key || parts.size != 2) { + err.println("Found option '${arg}', expected '${key}=<value>'") + return null + } + return parts[1] } } } diff --git a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt index 1fa90f7..4cdb589 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/Formatter.kt @@ -88,13 +88,7 @@ object Formatter { val lfCode = StringUtilRt.convertLineSeparators(kotlinCode) val sortedImports = sortedAndDistinctImports(lfCode) - val pretty = prettyPrint(sortedImports, options, "\n") - val noRedundantElements = - try { - dropRedundantElements(pretty, options) - } catch (e: ParseError) { - throw IllegalStateException("Failed to re-parse code after pretty printing:\n $pretty", e) - } + val noRedundantElements = dropRedundantElements(sortedImports, options) val prettyCode = prettyPrint(noRedundantElements, options, Newlines.guessLineSeparator(kotlinCode)!!) return if (shebang.isNotEmpty()) shebang + "\n" + prettyCode else prettyCode diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInput.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInput.kt index 52b01a4..f1efe5d 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInput.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInput.kt @@ -28,7 +28,6 @@ import com.google.googlejavaformat.Input import com.google.googlejavaformat.Newlines import com.google.googlejavaformat.java.FormatterException import com.google.googlejavaformat.java.JavaOutput -import java.util.LinkedHashMap import org.jetbrains.kotlin.com.intellij.openapi.util.text.StringUtil import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtFile @@ -55,16 +54,7 @@ class KotlinInput(private val text: String, file: KtFile) : Input() { val toks = buildToks(file, text) positionToColumnMap = makePositionToColumnMap(toks) tokens = buildTokens(toks) - val tokenLocations = ImmutableRangeMap.builder<Int, Token>() - for (token in tokens) { - val end = JavaOutput.endTok(token) - var upper = end.position - if (end.text.isNotEmpty()) { - upper += end.length() - 1 - } - tokenLocations.put(Range.closed(JavaOutput.startTok(token).position, upper), token) - } - positionTokenMap = tokenLocations.build() + positionTokenMap = buildTokenPositionsMap(tokens) // adjust kN for EOF kToToken = arrayOfNulls(kN + 1) @@ -134,13 +124,8 @@ class KotlinInput(private val text: String, file: KtFile) : Input() { enclosed.iterator().next().tok.index, getLast(enclosed).getTok().getIndex() + 1) } - private fun makePositionToColumnMap(toks: List<KotlinTok>): ImmutableMap<Int, Int> { - val builder = LinkedHashMap<Int, Int>() - for (tok in toks) { - builder.put(tok.position, tok.column) - } - return ImmutableMap.copyOf(builder) - } + private fun makePositionToColumnMap(toks: List<KotlinTok>) = + ImmutableMap.copyOf(toks.map { it.position to it.column }.toMap()) private fun buildToks(file: KtFile, fileText: String): ImmutableList<KotlinTok> { val tokenizer = Tokenizer(fileText, file) @@ -207,6 +192,17 @@ class KotlinInput(private val text: String, file: KtFile) : Input() { return tokens.build() } + private fun buildTokenPositionsMap(tokens: ImmutableList<Token>): ImmutableRangeMap<Int, Token> { + val tokenLocations = ImmutableRangeMap.builder<Int, Token>() + for (token in tokens) { + val end = JavaOutput.endTok(token) + val endPosition = end.position + (if (end.text.isNotEmpty()) end.length() - 1 else 0) + tokenLocations.put(Range.closed(JavaOutput.startTok(token).position, endPosition), token) + } + + return tokenLocations.build() + } + private fun isParamComment(tok: Tok): Boolean { return tok.isSlashStarComment && tok.text.matches("/\\*[A-Za-z0-9\\s_\\-]+=\\s*\\*/".toRegex()) } diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt index 358fe50..898b70e 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinInputAstVisitor.kt @@ -70,6 +70,7 @@ import org.jetbrains.kotlin.psi.KtFunctionType import org.jetbrains.kotlin.psi.KtIfExpression import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtImportList +import org.jetbrains.kotlin.psi.KtIntersectionType import org.jetbrains.kotlin.psi.KtIsExpression import org.jetbrains.kotlin.psi.KtLabelReferenceExpression import org.jetbrains.kotlin.psi.KtLabeledExpression @@ -231,18 +232,27 @@ class KotlinInputAstVisitor( } } + /** Example: `A & B`, */ + override fun visitIntersectionType(type: KtIntersectionType) { + builder.sync(type) + + // TODO(strulovich): Should this have the same indentation behaviour as `x && y`? + visit(type.getLeftTypeRef()) + builder.space() + builder.token("&") + builder.space() + visit(type.getRightTypeRef()) + } + /** Example `<Int, String>` in `List<Int, String>` */ override fun visitTypeArgumentList(typeArgumentList: KtTypeArgumentList) { builder.sync(typeArgumentList) - builder.block(ZERO) { - builder.token("<") - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) - builder.block(ZERO) { - emitParameterLikeList( - typeArgumentList.arguments, typeArgumentList.trailingComma != null, wrapInBlock = true) - } - } - builder.token(">") + visitEachCommaSeparated( + typeArgumentList.arguments, + typeArgumentList.trailingComma != null, + prefix = "<", + postfix = ">", + ) } override fun visitTypeProjection(typeProjection: KtTypeProjection) { @@ -456,7 +466,18 @@ class KotlinInputAstVisitor( visit(selectorExpression) } } - receiver is KtWhenExpression || receiver is KtStringTemplateExpression -> { + receiver is KtStringTemplateExpression -> { + val isMultiline = receiver.text.contains('\n') + builder.block(if (isMultiline) expressionBreakIndent else ZERO) { + visit(receiver) + if (isMultiline) { + builder.forcedBreak() + } + builder.token(expression.operationSign.value) + visit(expression.selectorExpression) + } + } + receiver is KtWhenExpression -> { builder.block(ZERO) { visit(receiver) builder.token(expression.operationSign.value) @@ -522,6 +543,7 @@ class KotlinInputAstVisitor( } val argsIndentElse = if (index == parts.size - 1) ZERO else expressionBreakIndent val lambdaIndentElse = if (isTrailingLambda) expressionBreakNegativeIndent else ZERO + val negativeLambdaIndentElse = if (isTrailingLambda) expressionBreakIndent else ZERO // emit `(1, 2) { it }` from `doIt(1, 2) { it }` visitCallElement( @@ -531,6 +553,7 @@ class KotlinInputAstVisitor( selectorExpression.lambdaArguments, argumentsIndent = Indent.If.make(nameTag, expressionBreakIndent, argsIndentElse), lambdaIndent = Indent.If.make(nameTag, ZERO, lambdaIndentElse), + negativeLambdaIndent = Indent.If.make(nameTag, ZERO, negativeLambdaIndentElse), ) } } @@ -714,82 +737,107 @@ class KotlinInputAstVisitor( typeArgumentList, valueArgumentList, lambdaArguments, - lambdaIndent = ZERO) + ) } } - /** Examples `foo<T>(a, b)`, `foo(a)`, `boo()`, `super(a)` */ + /** + * Examples `foo<T>(a, b)`, `foo(a)`, `boo()`, `super(a)` + * + * @param lambdaIndent how to indent [lambdaArguments], if present + * @param negativeLambdaIndent the negative indentation of [lambdaIndent] + */ private fun visitCallElement( callee: KtExpression?, typeArgumentList: KtTypeArgumentList?, argumentList: KtValueArgumentList?, lambdaArguments: List<KtLambdaArgument>, argumentsIndent: Indent = expressionBreakIndent, - lambdaIndent: Indent = ZERO + lambdaIndent: Indent = ZERO, + negativeLambdaIndent: Indent = ZERO, ) { - builder.block(ZERO) { - visit(callee) - val arguments = argumentList?.arguments.orEmpty() - builder.block(argumentsIndent) { visit(typeArgumentList) } - builder.block(argumentsIndent) { - if (argumentList != null) { - builder.token("(") - } - if (arguments.isNotEmpty()) { - if (isGoogleStyle) { - visit(argumentList) - val first = arguments.first() - if (arguments.size != 1 || - first?.isNamed() != false || - first.getArgumentExpression() !is KtLambdaExpression) { - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakNegativeIndent) - } - } else { - builder.block(ZERO) { visit(argumentList) } + // Apply the lambda indent to the callee, type args, value args, and the lambda. + // This is undone for the first three by the negative lambda indent. + // This way they're in one block, and breaks in the argument list cause a break in the lambda. + builder.block(lambdaIndent) { + + // Used to keep track of whether or not we need to indent the lambda + // This is based on if there is a break in the argument list + var brokeBeforeBrace: BreakTag? = null + + builder.block(negativeLambdaIndent) { + visit(callee) + builder.block(argumentsIndent) { + builder.block(ZERO) { visit(typeArgumentList) } + if (argumentList != null) { + brokeBeforeBrace = visitValueArgumentListInternal(argumentList) } } - if (argumentList != null) { - builder.token(")") - } } - val hasTrailingComma = argumentList?.trailingComma != null if (lambdaArguments.isNotEmpty()) { builder.space() - builder.block(lambdaIndent) { - lambdaArguments.forEach { - visitArgumentInternal(it, forceBreakLambdaBody = hasTrailingComma) - } - } + visitArgumentInternal( + lambdaArguments.single(), + wrapInBlock = false, + brokeBeforeBrace = brokeBeforeBrace, + ) } } } /** Example (`1, "hi"`) in a function call */ override fun visitValueArgumentList(list: KtValueArgumentList) { + visitValueArgumentListInternal(list) + } + + /** + * Example (`1, "hi"`) in a function call + * + * @return a [BreakTag] which can tell you if a break was taken, but only when the list doesn't + * terminate in a negative closing indent. See [visitEachCommaSeparated] for examples. + */ + private fun visitValueArgumentListInternal(list: KtValueArgumentList): BreakTag? { builder.sync(list) + val arguments = list.arguments val isSingleUnnamedLambda = arguments.size == 1 && arguments.first().getArgumentExpression() is KtLambdaExpression && arguments.first().getArgumentName() == null + val hasTrailingComma = list.trailingComma != null + + val wrapInBlock: Boolean + val breakBeforePostfix: Boolean + val leadingBreak: Boolean + val breakAfterPrefix: Boolean + if (isSingleUnnamedLambda) { - builder.block(expressionBreakNegativeIndent) { - visit(arguments.first()) - if (list.trailingComma != null) { - builder.token(",") - } - } + wrapInBlock = true + breakBeforePostfix = false + leadingBreak = arguments.isNotEmpty() && hasTrailingComma + breakAfterPrefix = false } else { - // Break before args. - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) - emitParameterLikeList( - list.arguments, list.trailingComma != null, wrapInBlock = !isGoogleStyle) - } + wrapInBlock = !isGoogleStyle + breakBeforePostfix = isGoogleStyle && arguments.isNotEmpty() + leadingBreak = arguments.isNotEmpty() + breakAfterPrefix = arguments.isNotEmpty() + } + + return visitEachCommaSeparated( + list.arguments, + hasTrailingComma, + wrapInBlock = wrapInBlock, + breakBeforePostfix = breakBeforePostfix, + leadingBreak = leadingBreak, + prefix = "(", + postfix = ")", + breakAfterPrefix = breakAfterPrefix, + ) } /** Example `{ 1 + 1 }` (as lambda) or `{ (x, y) -> x + y }` */ override fun visitLambdaExpression(lambdaExpression: KtLambdaExpression) { - visitLambdaExpressionInternal(lambdaExpression, brokeBeforeBrace = null, forceBreakBody = false) + visitLambdaExpressionInternal(lambdaExpression, brokeBeforeBrace = null) } /** @@ -806,21 +854,10 @@ class KotlinInputAstVisitor( * car() * } * ``` - * @param forceBreakBody if true, forces the lambda to be multi-line. Useful for call expressions - * where it would look weird for the lambda to be on one-line. For example, here we avoid - * one-lining `{ x = 0 }` since the parameters have a trailing comma: - * ``` - * foo.bar( - * trailingComma, - * ) { - * x = 0 - * } - * ``` */ private fun visitLambdaExpressionInternal( lambdaExpression: KtLambdaExpression, brokeBeforeBrace: BreakTag?, - forceBreakBody: Boolean, ) { builder.sync(lambdaExpression) @@ -855,9 +892,7 @@ class KotlinInputAstVisitor( if (hasParams || hasArrow) { builder.space() - builder.block(bracePlusExpressionIndent) { - forEachCommaSeparated(valueParams) { it.accept(this) } - } + builder.block(bracePlusExpressionIndent) { visitEachCommaSeparated(valueParams) } builder.block(bracePlusBlockIndent) { if (lambdaExpression.functionLiteral.valueParameterList?.trailingComma != null) { builder.token(",") @@ -870,10 +905,6 @@ class KotlinInputAstVisitor( builder.breakOp(Doc.FillMode.UNIFIED, "", bracePlusZeroIndent) } - if (forceBreakBody) { - builder.forcedBreak() - } - if (hasStatements) { builder.breakOp(Doc.FillMode.UNIFIED, " ", bracePlusBlockIndent) builder.block(bracePlusBlockIndent) { @@ -931,32 +962,11 @@ class KotlinInputAstVisitor( /** e.g., `a: Int, b: Int, c: Int` in `fun foo(a: Int, b: Int, c: Int) { ... }`. */ override fun visitParameterList(list: KtParameterList) { - emitParameterLikeList(list.parameters, list.trailingComma != null, wrapInBlock = false) - } - - /** - * Emit a list of elements that look like function parameters or arguments, e.g., `a, b, c` in - * `foo(a, b, c)` - */ - private fun <T : PsiElement> emitParameterLikeList( - list: List<T>?, - hasTrailingComma: Boolean, - wrapInBlock: Boolean - ) { - if (list.isNullOrEmpty()) { - return - } - - forEachCommaSeparated(list, hasTrailingComma, wrapInBlock, trailingBreak = isGoogleStyle) { - visit(it) - } - if (hasTrailingComma) { - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakNegativeIndent) - } + visitEachCommaSeparated(list.parameters, list.trailingComma != null, wrapInBlock = false) } /** - * Call `function` for each element in `list`, with comma (,) tokens inbetween. + * Visit each element in [list], with comma (,) tokens in-between. * * Example: * ``` @@ -973,6 +983,15 @@ class KotlinInputAstVisitor( * 5 * ``` * + * Optionally include a prefix and postfix: + * ``` + * ( + * a, + * b, + * c, + * ) + * ``` + * * @param hasTrailingComma if true, each element is placed on its own line (even if they could've * fit in a single line), and a trailing comma is emitted. * @@ -981,88 +1000,160 @@ class KotlinInputAstVisitor( * a, * b, * ``` + * + * @param wrapInBlock if true, place all the elements in a block. When there's no [leadingBreak], + * this will be negatively indented. Note that the [prefix] and [postfix] aren't included in the + * block. + * @param leadingBreak if true, break before the first element. + * @param prefix if provided, emit this before the first element. + * @param postfix if provided, emit this after the last element (or trailing comma). + * @param breakAfterPrefix if true, emit a break after [prefix], but before the start of the + * block. + * @param breakBeforePostfix if true, place a break after the last element. Redundant when + * [hasTrailingComma] is true. + * @return a [BreakTag] which can tell you if a break was taken, but only when the list doesn't + * terminate in a negative closing indent. + * + * Example 1, this returns a BreakTag which tells you a break wasn't taken: + * ``` + * (arg1, arg2) + * ``` + * + * Example 2, this returns a BreakTag which tells you a break WAS taken: + * ``` + * ( + * arg1, + * arg2) + * ``` + * + * Example 3, this returns null: + * ``` + * ( + * arg1, + * arg2, + * ) + * ``` + * + * Example 4, this also returns null (similar to example 2, but Google style): + * ``` + * ( + * arg1, + * arg2 + * ) + * ``` */ - private fun <T> forEachCommaSeparated( - list: Iterable<T>, + private fun visitEachCommaSeparated( + list: Iterable<PsiElement>, hasTrailingComma: Boolean = false, wrapInBlock: Boolean = true, - trailingBreak: Boolean = false, - function: (T) -> Unit - ) { - if (hasTrailingComma) { - builder.block(ZERO) { - builder.forcedBreak() - for (value in list) { - function(value) - builder.token(",") - builder.forcedBreak() - } + leadingBreak: Boolean = true, + prefix: String? = null, + postfix: String? = null, + breakAfterPrefix: Boolean = true, + breakBeforePostfix: Boolean = isGoogleStyle, + ): BreakTag? { + val breakAfterLastElement = hasTrailingComma || (postfix != null && breakBeforePostfix) + val nameTag = if (breakAfterLastElement) null else genSym() + + if (prefix != null) { + builder.token(prefix) + if (breakAfterPrefix) { + builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO, Optional.ofNullable(nameTag)) } - return } - builder.block(ZERO, isEnabled = wrapInBlock) { + val breakType = if (hasTrailingComma) Doc.FillMode.FORCED else Doc.FillMode.UNIFIED + fun emitComma() { + builder.token(",") + builder.breakOp(breakType, " ", ZERO) + } + + val indent = if (leadingBreak) ZERO else expressionBreakNegativeIndent + builder.block(indent, isEnabled = wrapInBlock) { + if (leadingBreak) { + builder.breakOp(breakType, "", ZERO) + } + var first = true - builder.breakOp(Doc.FillMode.UNIFIED, "", ZERO) for (value in list) { - if (!first) { - builder.token(",") - builder.breakOp(Doc.FillMode.UNIFIED, " ", ZERO) - } + if (!first) emitComma() first = false + visit(value) + } - function(value) + if (hasTrailingComma) { + emitComma() } } - if (trailingBreak) { - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakNegativeIndent) + + if (breakAfterLastElement) { + // a negative closing indent places the postfix to the left of the elements + // see examples 2 and 4 in the docstring + builder.breakOp(breakType, "", expressionBreakNegativeIndent) } + + if (postfix != null) { + if (breakAfterLastElement) { + // Indent trailing comments to the same depth as list items. We really have to fight + // googlejavaformat here for some reason. + builder.blankLineWanted(OpsBuilder.BlankLineWanted.NO) + builder.block(expressionBreakNegativeIndent) { + builder.breakOp(breakType, "", ZERO) + builder.token(postfix, expressionBreakIndent) + } + } else { + builder.token(postfix) + } + } + + return nameTag } /** Example `a` in `foo(a)`, or `*a`, or `limit = 50` */ override fun visitArgument(argument: KtValueArgument) { - visitArgumentInternal(argument, forceBreakLambdaBody = false) + visitArgumentInternal( + argument, + wrapInBlock = true, + brokeBeforeBrace = null, + ) } /** * The internal version of [visitArgument]. * - * @param forceBreakLambdaBody if true (and [argument] is of type [KtLambdaExpression]), forces - * the lambda to be multi-line. See documentation of [visitLambdaExpressionInternal] for an - * example. + * @param wrapInBlock if true places the argument expression in a block. */ private fun visitArgumentInternal( argument: KtValueArgument, - forceBreakLambdaBody: Boolean, + wrapInBlock: Boolean, + brokeBeforeBrace: BreakTag?, ) { builder.sync(argument) val hasArgName = argument.getArgumentName() != null val isLambda = argument.getArgumentExpression() is KtLambdaExpression - builder.block(ZERO) { - if (hasArgName) { - visit(argument.getArgumentName()) + if (hasArgName) { + visit(argument.getArgumentName()) + builder.space() + builder.token("=") + if (isLambda) { builder.space() - builder.token("=") - if (isLambda) { - builder.space() - } } - builder.block(if (hasArgName && !isLambda) expressionBreakIndent else ZERO) { - if (hasArgName && !isLambda) { - builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) - } - if (argument.isSpread) { - builder.token("*") - } - if (isLambda) { - visitLambdaExpressionInternal( - argument.getArgumentExpression() as KtLambdaExpression, - brokeBeforeBrace = null, - forceBreakBody = forceBreakLambdaBody, - ) - } else { - visit(argument.getArgumentExpression()) - } + } + val indent = if (hasArgName && !isLambda) expressionBreakIndent else ZERO + builder.block(indent, isEnabled = wrapInBlock) { + if (hasArgName && !isLambda) { + builder.breakOp(Doc.FillMode.INDEPENDENT, " ", ZERO) + } + if (argument.isSpread) { + builder.token("*") + } + if (isLambda) { + visitLambdaExpressionInternal( + argument.getArgumentExpression() as KtLambdaExpression, + brokeBeforeBrace = brokeBeforeBrace, + ) + } else { + visit(argument.getArgumentExpression()) } } } @@ -1357,11 +1448,7 @@ class KotlinInputAstVisitor( else -> throw AssertionError(expr) } - visitLambdaExpressionInternal( - lambdaExpression, - brokeBeforeBrace = breakToExpr, - forceBreakBody = false, - ) + visitLambdaExpressionInternal(lambdaExpression, brokeBeforeBrace = breakToExpr) } override fun visitClassOrObject(classOrObject: KtClassOrObject) { @@ -1510,7 +1597,7 @@ class KotlinInputAstVisitor( call.typeArgumentList, call.valueArgumentList, call.lambdaArguments, - lambdaIndent = ZERO) + ) } } @@ -1744,7 +1831,7 @@ class KotlinInputAstVisitor( override fun visitSuperTypeList(list: KtSuperTypeList) { builder.sync(list) - builder.block(expressionBreakIndent) { forEachCommaSeparated(list.entries) { visit(it) } } + builder.block(expressionBreakIndent) { visitEachCommaSeparated(list.entries) } } override fun visitSuperTypeCallEntry(call: KtSuperTypeCallEntry) { @@ -1891,7 +1978,7 @@ class KotlinInputAstVisitor( builder.token("[") builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) builder.block(expressionBreakIndent) { - emitParameterLikeList( + visitEachCommaSeparated( expression.indexExpressions, expression.trailingComma != null, wrapInBlock = true) } } @@ -1911,7 +1998,7 @@ class KotlinInputAstVisitor( builder.token("(") builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) builder.block(expressionBreakIndent) { - emitParameterLikeList( + visitEachCommaSeparated( destructuringDeclaration.entries, hasTrailingComma, wrapInBlock = true) } } @@ -1973,7 +2060,7 @@ class KotlinInputAstVisitor( // Break before args. builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) builder.block(expressionBreakIndent) { - emitParameterLikeList(list.parameters, list.trailingComma != null, wrapInBlock = true) + visitEachCommaSeparated(list.parameters, list.trailingComma != null, wrapInBlock = true) } } builder.token(">") @@ -1998,7 +2085,7 @@ class KotlinInputAstVisitor( builder.token("where") builder.space() builder.sync(list) - forEachCommaSeparated(list.constraints) { visit(it) } + visitEachCommaSeparated(list.constraints) } /** Example `T : Foo` */ @@ -2137,10 +2224,16 @@ class KotlinInputAstVisitor( builder.token(".") } builder.block(expressionBreakIndent) { - builder.token("(") - visit(type.parameterList) + val parameterList = type.parameterList + if (parameterList != null) { + visitEachCommaSeparated( + parameterList.parameters, + prefix = "(", + postfix = ")", + hasTrailingComma = parameterList.trailingComma != null, + ) + } } - builder.token(")") builder.space() builder.token("->") builder.space() @@ -2192,15 +2285,14 @@ class KotlinInputAstVisitor( */ override fun visitCollectionLiteralExpression(expression: KtCollectionLiteralExpression) { builder.sync(expression) - builder.block(ZERO) { - builder.token("[") - builder.breakOp(Doc.FillMode.UNIFIED, "", expressionBreakIndent) - builder.block(expressionBreakIndent) { - emitParameterLikeList( - expression.getInnerExpressions(), expression.trailingComma != null, wrapInBlock = true) - } + builder.block(expressionBreakIndent) { + visitEachCommaSeparated( + expression.getInnerExpressions(), + expression.trailingComma != null, + prefix = "[", + postfix = "]", + wrapInBlock = true) } - builder.token("]") } override fun visitTryExpression(expression: KtTryExpression) { diff --git a/core/src/main/java/com/facebook/ktfmt/format/KotlinTok.kt b/core/src/main/java/com/facebook/ktfmt/format/KotlinTok.kt index d03a7e4..5db843e 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/KotlinTok.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/KotlinTok.kt @@ -26,7 +26,7 @@ class KotlinTok( private val originalText: String, private val text: String, private val position: Int, - private val columnI: Int, + private val column: Int, val isToken: Boolean, private val kind: KtToken ) : Input.Tok { @@ -41,7 +41,7 @@ class KotlinTok( override fun getPosition(): Int = position - override fun getColumn(): Int = columnI + override fun getColumn(): Int = column override fun isNewline(): Boolean = Newlines.isNewline(text) @@ -60,7 +60,7 @@ class KotlinTok( .add("index", index) .add("text", text) .add("position", position) - .add("columnI", columnI) + .add("column", column) .add("isToken", isToken) .toString() } diff --git a/core/src/main/java/com/facebook/ktfmt/format/RedundantElementRemover.kt b/core/src/main/java/com/facebook/ktfmt/format/RedundantElementRemover.kt index a073438..1c090fe 100644 --- a/core/src/main/java/com/facebook/ktfmt/format/RedundantElementRemover.kt +++ b/core/src/main/java/com/facebook/ktfmt/format/RedundantElementRemover.kt @@ -17,6 +17,7 @@ package com.facebook.ktfmt.format import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace import org.jetbrains.kotlin.kdoc.psi.impl.KDocImpl import org.jetbrains.kotlin.psi.KtImportList import org.jetbrains.kotlin.psi.KtPackageDirective @@ -65,9 +66,16 @@ object RedundantElementRemover { redundantImportDetector.getRedundantImportElements() for (element in elementsToRemove.sortedByDescending(PsiElement::endOffset)) { - result.replace(element.startOffset, element.endOffset, "") + // Don't insert extra newlines when the semicolon is already a line terminator + val replacement = if (element.nextSibling.containsNewline()) "" else "\n" + result.replace(element.startOffset, element.endOffset, replacement) } return result.toString() } + + private fun PsiElement?.containsNewline(): Boolean { + if (this !is PsiWhiteSpace) return false + return this.text.contains('\n') + } } diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/CommentType.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/CommentType.kt new file mode 100644 index 0000000..aba6176 --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/CommentType.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +enum class CommentType( + /** The opening string of the comment. */ + val prefix: String, + /** The closing string of the comment. */ + val suffix: String, + /** For multi line comments, the prefix at each comment line after the first one. */ + val linePrefix: String +) { + KDOC("/**", "*/", " * "), + BLOCK("/*", "*/", ""), + LINE("//", "", "// "); + + /** + * The number of characters needed to fit a comment on a line: the prefix, suffix and a single + * space padding inside these. + */ + fun singleLineOverhead(): Int { + return prefix.length + suffix.length + 1 + if (suffix.isEmpty()) 0 else 1 + } + + /** + * The number of characters required in addition to the line comment for each line in a multi line + * comment. + */ + fun lineOverhead(): Int { + return linePrefix.length + } +} + +fun String.isKDocComment(): Boolean = startsWith("/**") + +fun String.isBlockComment(): Boolean = startsWith("/*") && !startsWith("/**") + +fun String.isLineComment(): Boolean = startsWith("//") + +fun String.commentType(): CommentType { + return if (isKDocComment()) { + CommentType.KDOC + } else if (isBlockComment()) { + CommentType.BLOCK + } else if (isLineComment()) { + CommentType.LINE + } else { + error("Not a comment: $this") + } +} diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/FormattingTask.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/FormattingTask.kt new file mode 100644 index 0000000..4f195db --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/FormattingTask.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +class FormattingTask( + /** Options to format with */ + var options: KDocFormattingOptions, + + /** The original comment to be formatted */ + var comment: String, + + /** + * The initial indentation on the first line of the KDoc. The reformatted comment will prefix + * each subsequent line with this string. + */ + var initialIndent: String, + + /** + * Indent to use after the first line. + * + * This is useful when the comment starts the end of an existing code line. For example, + * something like this: + * ``` + * if (foo.bar.baz()) { // This comment started at column 25 + * // but the second and subsequent lines are indented 8 spaces + * // ... + * ``` + * + * (This doesn't matter much for KDoc comments, since the formatter will always push these into + * their own lines so the indents will match, but for line and block comments it can matter.) + */ + var secondaryIndent: String = initialIndent, + + /** + * Optional list of parameters associated with this doc; if set, and if + * [KDocFormattingOptions.orderDocTags] is set, parameter doc tags will be sorted to match this + * order. (The intent is for the tool invoking KDocFormatter to pass in the parameter names in + * signature order here.) + */ + var orderedParameterNames: List<String> = emptyList(), + + /** The type of comment being formatted. */ + val type: CommentType = comment.commentType() +) diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/KDocCommentsHelper.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocCommentsHelper.kt index a63a332..3cf3c64 100644 --- a/core/src/main/java/com/facebook/ktfmt/kdoc/KDocCommentsHelper.kt +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocCommentsHelper.kt @@ -29,8 +29,16 @@ import java.util.ArrayList import java.util.regex.Pattern /** `KDocCommentsHelper` extends [CommentsHelper] to rewrite KDoc comments. */ -class KDocCommentsHelper(private val lineSeparator: String, private val maxLineLength: Int) : - CommentsHelper { +class KDocCommentsHelper(private val lineSeparator: String, maxLineLength: Int) : CommentsHelper { + + private val kdocFormatter = + KDocFormatter( + KDocFormattingOptions(maxLineLength, maxLineLength).also { + it.allowParamBrackets = true // TODO Do we want this? + it.convertMarkup = false + it.nestedListIndent = 4 + it.optimal = false // Use greedy line breaking for predictability. + }) override fun rewrite(tok: Tok, maxWidth: Int, column0: Int): String { if (!tok.isComment) { @@ -38,7 +46,7 @@ class KDocCommentsHelper(private val lineSeparator: String, private val maxLineL } var text = tok.originalText if (tok.isJavadocComment) { - text = KDocFormatter.formatKDoc(text, column0, maxLineLength) + text = kdocFormatter.reformatComment(text, " ".repeat(column0)) } val lines = ArrayList<String>() val it = Newlines.lineIterator(text) diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormatter.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormatter.kt index 8c0af07..d44561c 100644 --- a/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormatter.kt +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormatter.kt @@ -1,211 +1,160 @@ /* - * Copyright 2016 Google Inc. + * Copyright (c) Tor Norbye. * - * 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 + * 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. - */ - -/* - * This was copied from https://github.com/google/google-java-format and modified extensively to - * work for Kotlin formatting + * 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 com.facebook.ktfmt.kdoc -import com.facebook.ktfmt.kdoc.KDocToken.Type.BEGIN_KDOC -import com.facebook.ktfmt.kdoc.KDocToken.Type.BLANK_LINE -import com.facebook.ktfmt.kdoc.KDocToken.Type.CODE -import com.facebook.ktfmt.kdoc.KDocToken.Type.CODE_BLOCK_MARKER -import com.facebook.ktfmt.kdoc.KDocToken.Type.CODE_CLOSE_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.CODE_OPEN_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.END_KDOC -import com.facebook.ktfmt.kdoc.KDocToken.Type.LIST_ITEM_OPEN_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.LITERAL -import com.facebook.ktfmt.kdoc.KDocToken.Type.MARKDOWN_LINK -import com.facebook.ktfmt.kdoc.KDocToken.Type.PRE_CLOSE_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.PRE_OPEN_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.TABLE_CLOSE_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.TABLE_OPEN_TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.TAG -import com.facebook.ktfmt.kdoc.KDocToken.Type.WHITESPACE -import java.util.regex.Pattern.compile -import org.jetbrains.kotlin.com.intellij.psi.tree.IElementType -import org.jetbrains.kotlin.kdoc.lexer.KDocLexer -import org.jetbrains.kotlin.kdoc.lexer.KDocTokens -import org.jetbrains.kotlin.lexer.KtTokens.WHITE_SPACE - -/** - * Entry point for formatting KDoc. - * - * This stateless class reads tokens from the stateful lexer and translates them to "requests" and - * "writes" to the stateful writer. It also munges tokens into "standardized" forms. Finally, it - * performs postprocessing to convert the written KDoc to a one-liner if possible or to leave a - * single blank line if it's empty. - */ -object KDocFormatter { +import kotlin.math.min - private val ONE_CONTENT_LINE_PATTERN = compile(" */[*][*]\n *[*] (.*)\n *[*]/") +/** Formatter which can reformat KDoc comments. */ +class KDocFormatter(private val options: KDocFormattingOptions) { + /** Reformats the [comment], which follows the given [initialIndent] string. */ + fun reformatComment(comment: String, initialIndent: String): String { + return reformatComment(FormattingTask(options, comment, initialIndent)) + } - private val NUMBERED_LIST_PATTERN = "[0-9]+\\.".toRegex() + fun reformatComment(task: FormattingTask): String { + val indent = task.secondaryIndent + val indentSize = getIndentSize(indent, options) + val firstIndentSize = getIndentSize(task.initialIndent, options) + val comment = task.comment + val lineComment = comment.isLineComment() + val blockComment = comment.isBlockComment() + val paragraphs = ParagraphListBuilder(comment, options, task).scan(indentSize) + val commentType = task.type + val lineSeparator = "\n$indent${commentType.linePrefix}" + val prefix = commentType.prefix - /** - * Formats the given Javadoc comment, which must start with ∕✱✱ and end with ✱∕. The output will - * start and end with the same characters. - */ - fun formatKDoc(input: String, blockIndent: Int, maxLineLength: Int): String { - val escapedInput = Escaping.escapeKDoc(input) - val kDocLexer = KDocLexer() - kDocLexer.start(escapedInput) - val tokens = mutableListOf<KDocToken>() - var previousType: IElementType? = null - while (kDocLexer.tokenType != null) { - val tokenType = kDocLexer.tokenType - val tokenText = - with(kDocLexer.tokenText) { - if (previousType == KDocTokens.LEADING_ASTERISK && first() == ' ') substring(1) - else this - } + // Collapse single line? If alternate is turned on, use the opposite of the + // setting + val collapseLine = options.collapseSingleLine.let { if (options.alternate) !it else it } + if (paragraphs.isSingleParagraph() && collapseLine && !lineComment) { + // Does the text fit on a single line? + val trimmed = paragraphs.firstOrNull()?.text?.trim() ?: "" + // Subtract out space for "/** " and " */" and the indent: + val width = + min( + options.maxLineWidth - firstIndentSize - commentType.singleLineOverhead(), + options.maxCommentWidth) + val suffix = if (commentType.suffix.isEmpty()) "" else " ${commentType.suffix}" + if (trimmed.length <= width) { + return "$prefix $trimmed$suffix" + } + if (indentSize < firstIndentSize) { + val nextLineWidth = + min( + options.maxLineWidth - indentSize - commentType.singleLineOverhead(), + options.maxCommentWidth) + if (trimmed.length <= nextLineWidth) { + return "$prefix $trimmed$suffix" + } + } + } - processToken(tokenType, tokens, tokenText, previousType) + val sb = StringBuilder() - previousType = tokenType - kDocLexer.advance() + sb.append(prefix) + if (lineComment) { + sb.append(' ') + } else { + sb.append(lineSeparator) } - val result = render(tokens, blockIndent, maxLineLength) - return makeSingleLineIfPossible(blockIndent, result, maxLineLength) - } - private fun processToken( - tokenType: IElementType?, - tokens: MutableList<KDocToken>, - tokenText: String, - previousType: IElementType? - ) { - when (tokenType) { - KDocTokens.START -> tokens.add(KDocToken(BEGIN_KDOC, tokenText)) - KDocTokens.END -> tokens.add(KDocToken(END_KDOC, tokenText)) - KDocTokens.LEADING_ASTERISK -> Unit // Ignore, no need to output anything - KDocTokens.TAG_NAME -> tokens.add(KDocToken(TAG, tokenText)) - KDocTokens.CODE_BLOCK_TEXT -> tokens.add(KDocToken(CODE, tokenText)) - KDocTokens.MARKDOWN_INLINE_LINK, - KDocTokens.MARKDOWN_LINK -> { - tokens.add(KDocToken(MARKDOWN_LINK, tokenText)) + for (paragraph in paragraphs) { + if (paragraph.separate) { + // Remove trailing spaces which can happen when we have a paragraph + // separator + stripTrailingSpaces(lineComment, sb) + sb.append(lineSeparator) } - KDocTokens.MARKDOWN_ESCAPED_CHAR, - KDocTokens.TEXT -> { - var first = true - for (word in tokenizeKdocText(tokenText)) { - if (word.first().isWhitespace()) { - tokens.add(KDocToken(WHITESPACE, " ")) - continue - } - if (first) { - if (word == "-" || word == "*" || word.matches(NUMBERED_LIST_PATTERN)) { - tokens.add(KDocToken(LIST_ITEM_OPEN_TAG, "")) - } - first = false - } - // If the KDoc is malformed (e.g. unclosed code block) KDocLexer doesn't report an - // END_KDOC properly. We want to recover in such cases - if (word == "*/") { - tokens.add(KDocToken(END_KDOC, word)) - } else if (word.startsWith("```")) { - tokens.add(KDocToken(CODE_BLOCK_MARKER, word)) + val text = paragraph.text + if (paragraph.preformatted || paragraph.table) { + sb.append(text) + // Remove trailing spaces which can happen when we have an empty line in a + // preformatted paragraph. + stripTrailingSpaces(lineComment, sb) + sb.append(lineSeparator) + continue + } + + val lineWithoutIndent = options.maxLineWidth - commentType.lineOverhead() + val quoteAdjustment = if (paragraph.quoted) 2 else 0 + val maxLineWidth = + min(options.maxCommentWidth, lineWithoutIndent - indentSize) - quoteAdjustment + val firstMaxLineWidth = + if (sb.indexOf('\n') == -1) { + min(options.maxCommentWidth, lineWithoutIndent - firstIndentSize) - quoteAdjustment } else { - tokens.add(KDocToken(LITERAL, word)) + maxLineWidth } + + val lines = paragraph.reflow(firstMaxLineWidth, maxLineWidth) + var first = true + val hangingIndent = paragraph.hangingIndent + for (line in lines) { + sb.append(paragraph.indent) + if (first && !paragraph.continuation) { + first = false + } else { + sb.append(hangingIndent) } - } - WHITE_SPACE -> { - if (previousType == KDocTokens.LEADING_ASTERISK || tokenText.count { it == '\n' } >= 2) { - tokens.add(KDocToken(BLANK_LINE, "")) + if (paragraph.quoted) { + sb.append("> ") + } + if (line.isEmpty()) { + // Remove trailing spaces which can happen when we have a paragraph + // separator + stripTrailingSpaces(lineComment, sb) } else { - tokens.add(KDocToken(WHITESPACE, " ")) + sb.append(line) } + sb.append(lineSeparator) } - else -> throw RuntimeException("Unexpected: $tokenType") } - } - private fun render(input: List<KDocToken>, blockIndent: Int, maxLineLength: Int): String { - val output = KDocWriter(blockIndent, maxLineLength) - for (token in input) { - when (token.type) { - BEGIN_KDOC -> output.writeBeginJavadoc() - END_KDOC -> { - output.writeEndJavadoc() - return Escaping.unescapeKDoc(output.toString()) - } - LIST_ITEM_OPEN_TAG -> output.writeListItemOpen(token) - PRE_OPEN_TAG -> output.writePreOpen(token) - PRE_CLOSE_TAG -> output.writePreClose(token) - CODE_OPEN_TAG -> output.writeCodeOpen(token) - CODE_CLOSE_TAG -> output.writeCodeClose(token) - TABLE_OPEN_TAG -> output.writeTableOpen(token) - TABLE_CLOSE_TAG -> output.writeTableClose(token) - TAG -> output.writeTag(token) - CODE -> output.writeCodeLine(token) - CODE_BLOCK_MARKER -> output.writeExplicitCodeBlockMarker(token) - BLANK_LINE -> output.requestBlankLine() - WHITESPACE -> output.requestWhitespace() - LITERAL -> output.writeLiteral(token) - MARKDOWN_LINK -> output.writeMarkdownLink(token) - else -> throw AssertionError(token.type) + if (!lineComment) { + if (sb.endsWith("* ")) { + sb.setLength(sb.length - 2) } + sb.append("*/") + } else if (sb.endsWith(lineSeparator)) { + @Suppress("ReturnValueIgnored") sb.removeSuffix(lineSeparator) } - throw AssertionError() - } - /** - * Returns the given string or a one-line version of it (e.g., "∕✱✱ Tests for foos. ✱∕") if it - * fits on one line. - */ - private fun makeSingleLineIfPossible( - blockIndent: Int, - input: String, - maxLineLength: Int - ): String { - val oneLinerContentLength = maxLineLength - "/** */".length - blockIndent - val matcher = ONE_CONTENT_LINE_PATTERN.matcher(input) - if (matcher.matches() && matcher.group(1).isEmpty()) { - return "/** */" - } else if (matcher.matches() && matcher.group(1).length <= oneLinerContentLength) { - return "/** " + matcher.group(1) + " */" + val formatted = + if (lineComment) { + sb.trim().removeSuffix("//").trim().toString() + } else if (blockComment) { + sb.toString().replace(lineSeparator + "\n", "\n\n") + } else { + sb.toString() + } + + val separatorIndex = comment.indexOf('\n') + return if (separatorIndex > 0 && comment[separatorIndex - 1] == '\r') { + // CRLF separator + formatted.replace("\n", "\r\n") + } else { + formatted } - return input } - /** - * tokenizeKdocText splits 's' by whitespace, and returns both whitespace and non-whitespace - * parts. - * - * Multiple adjacent whitespace characters are collapsed into one. Trailing and leading spaces are - * included in the result. - * - * Example: `" one two three "` becomes `[" ", "one", " ", "two", " ", "three", " "]`. See tests - * for more examples. - */ - fun tokenizeKdocText(s: String) = sequence { - if (s.isEmpty()) { - return@sequence - } - var mark = 0 - var inWhitespace = s[0].isWhitespace() - for (i in 1..s.lastIndex) { - if (inWhitespace == s[i].isWhitespace()) { - continue - } - val result = if (inWhitespace) " " else s.substring(mark, i) - inWhitespace = s[i].isWhitespace() - mark = i - yield(result) + private fun stripTrailingSpaces(lineComment: Boolean, sb: StringBuilder) { + if (!lineComment && sb.endsWith("* ")) { + sb.setLength(sb.length - 1) + } else if (lineComment && sb.endsWith("// ")) { + sb.setLength(sb.length - 1) } - yield(if (inWhitespace) " " else s.substring(mark, s.length)) } } diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormattingOptions.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormattingOptions.kt new file mode 100644 index 0000000..bfe80ea --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormattingOptions.kt @@ -0,0 +1,134 @@ +/* + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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. + */ + +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +import kotlin.math.min + +/** Options controlling how the [KDocFormatter] will behave. */ +class KDocFormattingOptions( + /** Right hand side margin to write lines at. */ + var maxLineWidth: Int = 72, + /** + * Limit comment to be at most [maxCommentWidth] characters even if more would fit on the line. + */ + var maxCommentWidth: Int = min(maxLineWidth, 72) +) { + /** Whether to collapse multi-line comments that would fit on a single line into a single line. */ + var collapseSingleLine: Boolean = true + + /** Whether to collapse repeated spaces. */ + var collapseSpaces: Boolean = true + + /** Whether to convert basic markup like **bold** into **bold**, < into <, etc. */ + var convertMarkup: Boolean = true + + /** + * Whether to add punctuation where missing, such as ending sentences with a period. (TODO: Make + * sure the FIRST sentence ends with one too! Especially if the subsequent sentence is separated.) + */ + var addPunctuation: Boolean = false + + /** + * How many spaces to use for hanging indents in numbered lists and after block tags. Using 4 or + * more here will result in subsequent lines being interpreted as block formatted. + */ + var hangingIndent: Int = 2 + + /** When there are nested lists etc, how many spaces to indent by. */ + var nestedListIndent: Int = 3 + set(value) { + if (value < 3) { + error( + "Nested list indent must be at least 3; if list items are only indented 2 spaces they " + + "will not be rendered as list items") + } + field = value + } + + /** + * Don't format with tabs! (See + * https://kotlinlang.org/docs/reference/coding-conventions.html#formatting) + * + * But if you do, this is the tab width. + */ + var tabWidth: Int = 8 + + /** Whether to perform optimal line breaking instead of greeding. */ + var optimal: Boolean = true + + /** + * If true, reformat markdown tables such that the column markers line up. When false, markdown + * tables are left alone (except for left hand side cleanup.) + */ + var alignTableColumns: Boolean = true + + /** + * If true, moves any kdoc tags to the end of the comment and `@return` tags after `@param` tags. + */ + var orderDocTags: Boolean = true + + /** + * If true, perform "alternative" formatting. This is only relevant in the IDE. You can invoke the + * action repeatedly and it will jump between normal formatting an alternative formatting. For + * single-line comments it will alternate between single and multiple lines. For longer comments + * it will alternate between optimal line breaking and greedy line breaking. + */ + var alternate: Boolean = false + + /** + * KDoc allows param tag to be specified using an alternate bracket syntax. KDoc formatter ties to + * unify the format of comments, so it will rewrite them into the canonical syntax unless this + * option is true. + */ + var allowParamBrackets: Boolean = false + + /** Creates a copy of this formatting object. */ + fun copy(): KDocFormattingOptions { + val copy = KDocFormattingOptions() + copy.maxLineWidth = maxLineWidth + copy.maxCommentWidth = maxCommentWidth + copy.collapseSingleLine = collapseSingleLine + copy.collapseSpaces = collapseSpaces + copy.hangingIndent = hangingIndent + copy.tabWidth = tabWidth + copy.alignTableColumns = alignTableColumns + copy.orderDocTags = orderDocTags + copy.addPunctuation = addPunctuation + copy.convertMarkup = convertMarkup + copy.nestedListIndent = nestedListIndent + copy.optimal = optimal + copy.alternate = alternate + + return copy + } +} diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/Paragraph.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/Paragraph.kt new file mode 100644 index 0000000..93e98e5 --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/Paragraph.kt @@ -0,0 +1,617 @@ +/* + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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. + */ + +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +import kotlin.math.min + +class Paragraph(private val task: FormattingTask) { + private val options: KDocFormattingOptions + get() = task.options + var content = StringBuilder() + val text + get() = content.toString() + var prev: Paragraph? = null + var next: Paragraph? = null + + /** If true, this paragraph should be preceded by a blank line. */ + var separate = false + + /** + * If true, this paragraph is a continuation of the previous paragraph (so should be indented with + * the hanging indent, including line 1) + */ + var continuation = false + + /** + * Whether this paragraph is allowed to be empty. Paragraphs are normally merged if this is not + * set. This allows the line breaker to call [ParagraphListBuilder.newParagraph] repeatedly + * without introducing more than one new paragraph. But for preformatted text we do want to be + * able to express repeated blank lines. + */ + var allowEmpty = false + + /** Is this paragraph preformatted? */ + var preformatted = false + + /** Is this paragraph a block paragraph? If so, it must start on its own line. */ + var block = false + + /** Is this paragraph specifying a kdoc tag like @param? */ + var doc = false + + /** + * Is this line quoted? (In the future make this an int such that we can support additional + * levels.) + */ + var quoted = false + + /** Is this line part of a table? */ + var table = false + + /** Is this a separator line? */ + var separator = false + + /** Should this paragraph use a hanging indent? (Implies [block] as well). */ + var hanging = false + set(value) { + block = true + field = value + } + + var originalIndent = 0 + + // The indent to use for all lines in the paragraph. + var indent = "" + + // The indent to use for all lines in the paragraph if [hanging] is true, + // or the second and subsequent lines if [hanging] is false + var hangingIndent = "" + + fun isEmpty(): Boolean { + return content.isEmpty() + } + + private fun hasClosingPre(): Boolean { + return content.contains("</pre>", ignoreCase = false) || next?.hasClosingPre() ?: false + } + + fun cleanup() { + val original = text + + if (preformatted) { + return + } + + var s = original + if (options.convertMarkup) { + s = convertMarkup(text) + } + if (!options.allowParamBrackets) { + s = rewriteParams(s) + } + + if (s != original) { + content.clear() + content.append(s) + } + } + + private fun rewriteParams(s: String): String { + var start = 0 + val length = s.length + while (start < length && s[start].isWhitespace()) { + start++ + } + if (s.startsWith("@param", start)) { + start += "@param".length + while (start < length && s[start].isWhitespace()) { + start++ + } + if (start < length && s[start++] == '[') { + while (start < length && s[start].isWhitespace()) { + start++ + } + var end = start + while (end < length && s[end].isJavaIdentifierPart()) { + end++ + } + if (end > start) { + val name = s.substring(start, end) + while (end < length && s[end].isWhitespace()) { + end++ + } + if (end < length && s[end++] == ']') { + while (end < length && s[end].isWhitespace()) { + end++ + } + return "@param $name ${s.substring(end)}" + } + } + } + } + + return s + } + + private fun convertMarkup(s: String): String { + if (s.none { it == '<' || it == '&' || it == '{' }) return s + + val sb = StringBuilder(s.length) + var i = 0 + val n = s.length + var code = false + var brackets = 0 + while (i < n) { + val c = s[i++] + if (c == '\\') { + sb.append(c) + if (i < n - 1) { + sb.append(s[i++]) + } + continue + } else if (c == '`') { + code = !code + sb.append(c) + continue + } else if (c == '[') { + brackets++ + sb.append(c) + continue + } else if (c == ']') { + brackets-- + sb.append(c) + continue + } else if (code || brackets > 0) { + sb.append(c) + continue + } else if (c == '<') { + if (s.startsWith("b>", i, false) || s.startsWith("/b>", i, false)) { + // "<b>" or </b> -> "**" + sb.append('*').append('*') + if (s[i] == '/') i++ + i += 2 + continue + } + if (s.startsWith("i>", i, false) || s.startsWith("/i>", i, false)) { + // "<i>" or </i> -> "*" + sb.append('*') + if (s[i] == '/') i++ + i += 2 + continue + } + if (s.startsWith("em>", i, false) || s.startsWith("/em>", i, false)) { + // "<em>" or </em> -> "_" + sb.append('_') + if (s[i] == '/') i++ + i += 3 + continue + } + // (We don't convert <pre> here because those tags appear in paragraphs + // marked preformatted, and preformatted paragraphs are never passed to + // convertTags) + } else if (c == '&') { + if (s.startsWith("lt;", i, true)) { // "<" -> "<" + sb.append('<') + i += 3 + continue + } + if (s.startsWith("gt;", i, true)) { // ">" -> ">" + sb.append('>') + i += 3 + continue + } + } else if (c == '{') { + if (s.startsWith("@param", i, true)) { + val curr = i + 6 + var end = s.indexOf('}', curr) + if (end == -1) { + end = n + } + sb.append('[') + sb.append(s.substring(curr, end).trim()) + sb.append(']') + i = end + 1 + continue + } else if (s.startsWith("@link", i, true) + // @linkplain is similar to @link, but kdoc does *not* render a [symbol] + // into a {@linkplain} in HTML, so converting these would change the output. + && !s.startsWith("@linkplain", i, true)) { + // {@link} or {@linkplain} + sb.append('[') + var curr = i + 5 + while (curr < n) { + val ch = s[curr++] + if (ch.isWhitespace()) { + break + } + if (ch == '}') { + curr-- + break + } + } + var skip = false + while (curr < n) { + val ch = s[curr] + if (ch == '}') { + sb.append(']') + curr++ + break + } else if (ch == '(') { + skip = true + } else if (!skip) { + if (ch == '#') { + if (!sb.endsWith('[')) { + sb.append('.') + } + } else { + sb.append(ch) + } + } + curr++ + } + i = curr + continue + } + } + sb.append(c) + } + + return sb.toString() + } + + fun reflow(firstLineMaxWidth: Int, maxLineWidth: Int): List<String> { + val lineWidth = maxLineWidth - getIndentSize(indent, options) + val hangingIndentSize = getIndentSize(hangingIndent, options) - if (quoted) 2 else 0 // "> " + if (text.length < (firstLineMaxWidth - hangingIndentSize)) { + return listOf(text.collapseSpaces()) + } + // Split text into words + val words: List<String> = computeWords() + + // See divide & conquer algorithm listed here: https://xxyxyz.org/line-breaking/ + if (words.size == 1) { + return listOf(words[0]) + } + + if (firstLineMaxWidth < maxLineWidth) { + // We have ragged text. We'll just greedily place the first + // words on the first line, and then optimize the rest. + val line = StringBuilder() + val firstLineWidth = firstLineMaxWidth - getIndentSize(indent, options) + for (i in words.indices) { + val word = words[i] + if (line.isEmpty()) { + if (word.length + task.type.lineOverhead() > firstLineMaxWidth) { + // can't fit anything on the first line: just flow to + // full width and caller will need to insert comment on + // the next line. + return reflow(words, lineWidth, hangingIndentSize) + } + line.append(word) + } else if (line.length + word.length + 1 <= firstLineWidth) { + line.append(' ') + line.append(word) + } else { + // Break the rest + val remainingWords = words.subList(i, words.size) + val reflownRemaining = reflow(remainingWords, lineWidth, hangingIndentSize) + return listOf(line.toString()) + reflownRemaining + } + } + // We fit everything on the first line + return listOf(line.toString()) + } + + return reflow(words, lineWidth, hangingIndentSize) + } + + fun reflow(words: List<String>, lineWidth: Int, hangingIndentSize: Int): List<String> { + if (options.alternate || !options.optimal || hanging && hangingIndentSize > 0) { + // Switch to greedy if explicitly turned on, and for hanging indent + // paragraphs, since the current implementation doesn't have support + // for a different maximum length on the first line from the rest + // and there were various cases where this ended up with bad results. + // This is typically used in list items (and kdoc sections) which tend + // to be short -- and for 2-3 lines the gains of optimal line breaking + // isn't worth the cases where we have really unbalanced looking text + return reflowGreedy(lineWidth, options, words) + } + + val lines = reflowOptimal(lineWidth - hangingIndentSize, words) + if (lines.size <= 2) { + // Just 2 lines? We prefer long+short instead of half+half. + return reflowGreedy(lineWidth, options, words) + } else { + // We could just return [lines] here, but the straightforward algorithm + // doesn't do a great job with short paragraphs where the last line is + // short; it over-corrects and shortens everything else in order to balance + // out the last line. + + val maxLine: (String) -> Int = { + // Ignore lines that are unbreakable + if (it.indexOf(' ') == -1) { + 0 + } else { + it.length + } + } + val longestLine = lines.maxOf(maxLine) + var lastWord = words.size - 1 + while (lastWord > 0) { + // We can afford to do this because we're only repeating it for a single + // line's worth of words and because comments tend to be relatively short + // anyway + val newLines = reflowOptimal(lineWidth - hangingIndentSize, words.subList(0, lastWord)) + if (newLines.size < lines.size) { + val newLongestLine = newLines.maxOf(maxLine) + if (newLongestLine > longestLine && + newLines.subList(0, newLines.size - 1).any { it.length > longestLine }) { + return newLines + + reflowGreedy( + lineWidth - hangingIndentSize, options, words.subList(lastWord, words.size)) + } + break + } + lastWord-- + } + + return lines + } + } + + /** + * Returns true if it's okay to break at the current word. + * + * We need to check for this, because a word can have a different meaning at the beginning of a + * line than in the middle somewhere, so if it just so happens to be at the break boundary, we + * need to make sure we don't make it the first word on the next line since that would change the + * documentation. + */ + private fun canBreakAt(word: String): Boolean { + // Can we start a new line with this without interpreting it in a special + // way? + + if (word.startsWith("#") || + word.startsWith("```") || + word.isDirectiveMarker() || + word.startsWith("@") || // interpreted as a tag + word.isTodo()) { + return false + } + + if (!word.first().isLetter()) { + val wordWithSpace = "$word " // for regex matching in below checks + if (wordWithSpace.isListItem() && !word.equals("<li>", true) || wordWithSpace.isQuoted()) { + return false + } + } + + return true + } + + private fun computeWords(): List<String> { + val words = text.split(Regex("\\s+")).filter { it.isNotBlank() }.map { it.trim() } + if (words.size == 1) { + return words + } + + if (task.type != CommentType.KDOC) { + // In block comments and line comments we feel free to break anywhere + // between words; there isn't a special meaning assigned to certain words + // if they appear first on a line like there is in KDoc/Markdown. + return words + } + + // See if any of the words should never be broken up. We do that for list + // separators and a few others. We never want to put "1." at the beginning + // of a line as an overflow. + + val combined = ArrayList<String>(words.size) + + // If this paragraph is a list item or a quoted line, merge the first word + // with this item such that we never split them apart. + var start = 0 + var first = words[start++] + if (quoted || hanging && !text.isKDocTag()) { + first = first + " " + words[start++] + } + + combined.add(first) + var prev = first + var insideSquareBrackets = words[start - 1].startsWith("[") + for (i in start until words.size) { + val word = words[i] + + // We also cannot break up a URL text across lines, which will alter the + // rendering of the docs. + if (prev.startsWith("[")) insideSquareBrackets = true + if (prev.contains("]")) insideSquareBrackets = false + + // Can we start a new line with this without interpreting it in a special + // way? + if (!canBreakAt(word) || insideSquareBrackets) { + // Combine with previous word with a single space; the line breaking + // algorithm won't know that it's more than one word. + val joined = "$prev $word" + combined.removeLast() + combined.add(joined) + prev = joined + } else { + combined.add(word) + prev = word + } + } + return combined + } + + private data class Quadruple(val i0: Int, val j0: Int, val i1: Int, val j1: Int) + + private fun reflowOptimal(maxLineWidth: Int, words: List<String>): List<String> { + val count = words.size + val lines = ArrayList<String>() + + val offsets = ArrayList<Int>() + offsets.add(0) + + for (boxWidth in words.map { it.length }.toList()) { + offsets.add(offsets.last() + min(boxWidth, maxLineWidth)) + } + + val big = 10 shl 20 + val minimum = IntArray(count + 1) { big } + val breaks = IntArray(count + 1) + minimum[0] = 0 + + fun cost(i: Int, j: Int): Int { + val width = offsets[j] - offsets[i] + j - i - 1 + return if (width <= maxLineWidth) { + val squared = (maxLineWidth - width) * (maxLineWidth - width) + minimum[i] + squared + } else { + big + } + } + + fun search(pi0: Int, pj0: Int, pi1: Int, pj1: Int) { + val stack = java.util.ArrayDeque<Quadruple>() + stack.add(Quadruple(pi0, pj0, pi1, pj1)) + + while (stack.isNotEmpty()) { + val (i0, j0, i1, j1) = stack.removeLast() + if (j0 < j1) { + val j = (j0 + j1) / 2 + + for (i in i0 until i1) { + val c = cost(i, j) + if (c <= minimum[j]) { + minimum[j] = c + breaks[j] = i + } + } + stack.add(Quadruple(breaks[j], j + 1, i1, j1)) + stack.add(Quadruple(i0, j0, breaks[j] + 1, j)) + } + } + } + + var n = count + 1 + var i = 0 + var offset = 0 + + while (true) { + val r = min(n, 1 shl (i + 1)) + val edge = (1 shl i) + offset + search(0 + offset, edge, edge, r + offset) + val x = minimum[r - 1 + offset] + var flag = true + for (j in (1 shl i) until (r - 1)) { + val y = cost(j + offset, r - 1 + offset) + if (y <= x) { + n -= j + i = 0 + offset += j + flag = false + break + } + } + if (flag) { + if (r == n) break + i++ + } + } + + var j = count + while (j > 0) { + i = breaks[j] + val sb = StringBuilder() + for (w in i until j) { + sb.append(words[w]) + if (w < j - 1) { + sb.append(' ') + } + } + lines.add(sb.toString()) + j = i + } + + lines.reverse() + return lines + } + + private fun reflowGreedy( + lineWidth: Int, + options: KDocFormattingOptions, + words: List<String> + ): List<String> { + // Greedy implementation + + var width = lineWidth + if (options.hangingIndent > 0 && hanging && continuation) { + width -= getIndentSize(hangingIndent, options) + } + + val lines = mutableListOf<String>() + var column = 0 + val sb = StringBuilder() + for (word in words) { + when { + sb.isEmpty() -> { + sb.append(word) + column += word.length + } + column + word.length + 1 <= width -> { + sb.append(' ').append(word) + column += word.length + 1 + } + else -> { + width = lineWidth + if (options.hangingIndent > 0 && hanging) { + width -= getIndentSize(hangingIndent, options) + } + lines.add(sb.toString()) + sb.setLength(0) + sb.append(word) + column = sb.length + } + } + } + if (sb.isNotEmpty()) { + lines.add(sb.toString()) + } + return lines + } + + override fun toString(): String { + return "$content, separate=$separate, block=$block, hanging=$hanging, preformatted=$preformatted, quoted=$quoted, continuation=$continuation, allowempty=$allowEmpty, separator=$separator" + } +} diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphList.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphList.kt new file mode 100644 index 0000000..5130824 --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphList.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +/** + * A list of paragraphs. Each paragraph should start on a new line and end with a newline. In + * addition, if a paragraph is marked with "separate=true", we'll insert an extra blank line in + * front of it. + */ +class ParagraphList(private val paragraphs: List<Paragraph>) : Iterable<Paragraph> { + fun isSingleParagraph() = paragraphs.size <= 1 + override fun iterator(): Iterator<Paragraph> = paragraphs.iterator() + override fun toString(): String = paragraphs.joinToString { it.content } +} diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphListBuilder.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphListBuilder.kt new file mode 100644 index 0000000..cb0891e --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphListBuilder.kt @@ -0,0 +1,830 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +class ParagraphListBuilder( + comment: String, + private val options: KDocFormattingOptions, + private val task: FormattingTask +) { + private val lineComment: Boolean = comment.isLineComment() + private val commentPrefix: String = + if (lineComment) "//" else if (comment.isKDocComment()) "/**" else "/*" + private val paragraphs: MutableList<Paragraph> = mutableListOf() + private val lines = + if (lineComment) { + comment.split("\n").map { it.trimStart() } + } else if (!comment.contains("\n")) { + listOf("* ${comment.removePrefix(commentPrefix).removeSuffix("*/").trim()}") + } else { + comment.removePrefix(commentPrefix).removeSuffix("*/").trim().split("\n") + } + + private fun lineContent(line: String): String { + val trimmed = line.trim() + return when { + lineComment && trimmed.startsWith("// ") -> trimmed.substring(3) + lineComment && trimmed.startsWith("//") -> trimmed.substring(2) + trimmed.startsWith("* ") -> trimmed.substring(2) + trimmed.startsWith("*") -> trimmed.substring(1) + else -> trimmed + } + } + + private fun closeParagraph(): Paragraph { + val text = paragraph.text + when { + text.isKDocTag() -> { + paragraph.doc = true + paragraph.hanging = true + } + text.isTodo() -> { + paragraph.hanging = true + } + text.isListItem() -> paragraph.hanging = true + text.isDirectiveMarker() -> { + paragraph.block = true + paragraph.preformatted = true + } + } + if (!paragraph.isEmpty() || paragraph.allowEmpty) { + paragraphs.add(paragraph) + } + return paragraph + } + + private fun newParagraph(): Paragraph { + closeParagraph() + val prev = paragraph + paragraph = Paragraph(task) + prev.next = paragraph + paragraph.prev = prev + return paragraph + } + + private var paragraph = Paragraph(task) + + private fun appendText(s: String): ParagraphListBuilder { + paragraph.content.append(s) + return this + } + + private fun addLines( + i: Int, + includeEnd: Boolean = true, + until: (Int, String, String) -> Boolean = { _, _, _ -> true }, + customize: (Int, Paragraph) -> Unit = { _, _ -> }, + shouldBreak: (String, String) -> Boolean = { _, _ -> false }, + separator: String = " " + ): Int { + var j = i + while (j < lines.size) { + val l = lines[j] + val lineWithIndentation = lineContent(l) + val lineWithoutIndentation = lineWithIndentation.trim() + + if (!includeEnd) { + if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) { + stripTrailingBlankLines() + return j + } + } + + if (shouldBreak(lineWithoutIndentation, lineWithIndentation)) { + newParagraph() + } + + if (lineWithIndentation.isQuoted()) { + appendText(lineWithoutIndentation.substring(2).collapseSpaces()) + } else { + appendText(lineWithoutIndentation.collapseSpaces()) + } + appendText(separator) + customize(j, paragraph) + if (includeEnd) { + if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) { + stripTrailingBlankLines() + return j + 1 + } + } + + j++ + } + + stripTrailingBlankLines() + newParagraph() + + return j + } + + private fun addPreformatted( + i: Int, + includeStart: Boolean = false, + includeEnd: Boolean = true, + expectClose: Boolean = false, + customize: (Int, Paragraph) -> Unit = { _, _ -> }, + until: (String) -> Boolean = { true }, + ): Int { + newParagraph() + var j = i + var foundClose = false + var customize = true + while (j < lines.size) { + val l = lines[j] + val lineWithIndentation = lineContent(l) + if (lineWithIndentation.contains("```") && + lineWithIndentation.trimStart().startsWith("```")) { + // Don't convert <pre> tags if we already have nested ``` content; that will lead to trouble + customize = false + } + val done = (includeStart || j > i) && until(lineWithIndentation) + if (!includeEnd && done) { + foundClose = true + break + } + j++ + if (includeEnd && done) { + foundClose = true + break + } + } + + // Don't convert if there's already a mixture + + // We ran out of lines. This means we had an unterminated preformatted + // block. This is unexpected(unless it was an indented block) and most + // likely a documentation error (even Dokka will start formatting return + // value documentation in preformatted text if you have an opening <pre> + // without a closing </pre> before a @return comment), but try to backpedal + // a bit such that we don't apply full preformatted treatment everywhere to + // things like line breaking. + if (!foundClose && expectClose) { + // Just add a single line as preformatted and then treat the rest in the + // normal way + customize = false + j = lines.size + } + + for (index in i until j) { + val l = lines[index] + val lineWithIndentation = lineContent(l) + appendText(lineWithIndentation) + paragraph.preformatted = true + paragraph.allowEmpty = true + if (customize) { + customize(index, paragraph) + } + newParagraph() + } + stripTrailingBlankLines() + newParagraph() + + return j + } + + private fun stripTrailingBlankLines() { + for (p in paragraphs.size - 1 downTo 0) { + val paragraph = paragraphs[p] + if (!paragraph.isEmpty()) { + break + } + paragraphs.removeAt(p) + } + } + + fun scan(indentSize: Int): ParagraphList { + var i = 0 + while (i < lines.size) { + val l = lines[i++] + val lineWithIndentation = lineContent(l) + val lineWithoutIndentation = lineWithIndentation.trim() + + fun newParagraph(i: Int): Paragraph { + val paragraph = this.newParagraph() + + if (i >= 0 && i < lines.size) { + if (lines[i] == l) { + paragraph.originalIndent = lineWithIndentation.length - lineWithoutIndentation.length + } else { + // We've looked ahead, e.g. when adding lists etc + val line = lineContent(lines[i]) + val trimmed = line.trim() + paragraph.originalIndent = line.length - trimmed.length + } + } + return paragraph + } + + if (lineWithIndentation.startsWith(" ") && // markdown preformatted text + (i == 1 || lineContent(lines[i - 2]).isBlank()) && // we've already ++'ed i above + // Make sure it's not just deeply indented inside a different block + (paragraph.prev == null || + lineWithIndentation.length - lineWithoutIndentation.length >= + paragraph.prev!!.originalIndent + 4)) { + i = addPreformatted(i - 1, includeEnd = false, expectClose = false) { !it.startsWith(" ") } + } else if (lineWithoutIndentation.startsWith("-") && + lineWithoutIndentation.containsOnly('-', '|', ' ')) { + val paragraph = newParagraph(i - 1) + appendText(lineWithoutIndentation) + newParagraph(i).block = true + // Dividers must be surrounded by blank lines + if (lineWithIndentation.isLine() && + (i < 2 || lineContent(lines[i - 2]).isBlank()) && + (i > lines.size - 1 || lineContent(lines[i]).isBlank())) { + paragraph.separator = true + } + } else if (lineWithoutIndentation.startsWith("=") && + lineWithoutIndentation.containsOnly('=', ' ')) { + // Header + // ====== + newParagraph(i - 1).block = true + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } else if (lineWithoutIndentation.startsWith( + "#")) { // not isHeader() because <h> is handled separately + // ## Header + newParagraph(i - 1).block = true + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } else if (lineWithoutIndentation.startsWith("*") && + lineWithoutIndentation.containsOnly('*', ' ')) { + // Horizontal rule: + // ******* + // * * * + // Unlike --- lines, these aren't required to be preceded by or followed by + // blank lines. + newParagraph(i - 1).block = true + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } else if (lineWithoutIndentation.startsWith("```")) { + i = addPreformatted(i - 1, expectClose = true) { it.trimStart().startsWith("```") } + } else if (lineWithoutIndentation.startsWith("<pre>", ignoreCase = true)) { + i = + addPreformatted( + i - 1, + includeStart = true, + expectClose = true, + customize = { _, _ -> + if (options.convertMarkup) { + fun handleTag(tag: String) { + val text = paragraph.text + val trimmed = text.trim() + + val index = text.indexOf(tag, ignoreCase = true) + if (index == -1) { + return + } + paragraph.content.clear() + if (trimmed.equals(tag, ignoreCase = true)) { + paragraph.content.append("```") + return + } + + // Split paragraphs; these things have to be on their own line + // in the ``` form (unless both are in the middle) + val before = text.substring(0, index).replace("</code>", "", true).trim() + if (before.isNotBlank()) { + paragraph.content.append(before) + newParagraph() + paragraph.preformatted = true + paragraph.allowEmpty = true + } + appendText("```") + val after = + text.substring(index + tag.length).replace("<code>", "", true).trim() + if (after.isNotBlank()) { + newParagraph() + appendText(after) + paragraph.preformatted = true + paragraph.allowEmpty = true + } + } + + handleTag("<pre>") + handleTag("</pre>") + } + }, + until = { it.contains("</pre>", ignoreCase = true) }) + } else if (lineWithoutIndentation.isQuoted()) { + i-- + val paragraph = newParagraph(i) + paragraph.quoted = true + paragraph.block = false + i = + addLines( + i, + until = { _, w, _ -> + w.isBlank() || + w.isListItem() || + w.isKDocTag() || + w.isTodo() || + w.isDirectiveMarker() || + w.isHeader() + }, + customize = { _, p -> p.quoted = true }, + includeEnd = false) + newParagraph(i) + } else if (lineWithoutIndentation.equals("<ul>", true) || + lineWithoutIndentation.equals("<ol>", true)) { + newParagraph(i - 1).block = true + appendText(lineWithoutIndentation) + newParagraph(i).hanging = true + i = + addLines( + i, + includeEnd = true, + until = { _, w, _ -> w.equals("</ul>", true) || w.equals("</ol>", true) }, + customize = { _, p -> p.block = true }, + shouldBreak = { w, _ -> + w.startsWith("<li>", true) || + w.startsWith("</ul>", true) || + w.startsWith("</ol>", true) + }) + newParagraph(i) + } else if (lineWithoutIndentation.isListItem() || + lineWithoutIndentation.isKDocTag() && task.type == CommentType.KDOC || + lineWithoutIndentation.isTodo()) { + i-- + newParagraph(i).hanging = true + val start = i + i = + addLines( + i, + includeEnd = false, + until = { j: Int, w: String, s: String -> + // See if it's a line continuation + if (s.isBlank() && + j < lines.size - 1 && + lineContent(lines[j + 1]).startsWith(" ")) { + false + } else { + s.isBlank() || + w.isListItem() || + w.isQuoted() || + w.isKDocTag() || + w.isTodo() || + s.startsWith("```") || + w.startsWith("<pre>") || + w.isDirectiveMarker() || + w.isLine() || + w.isHeader() || + // Not indented by at least two spaces following a blank line? + s.length > 2 && + (!s[0].isWhitespace() || !s[1].isWhitespace()) && + j < lines.size - 1 && + lineContent(lines[j - 1]).isBlank() + } + }, + shouldBreak = { w, _ -> w.isBlank() }, + customize = { j, p -> + if (lineContent(lines[j]).isBlank() && j >= start) { + p.hanging = true + p.continuation = true + } + }) + newParagraph(i) + } else if (lineWithoutIndentation.isEmpty()) { + newParagraph(i).separate = true + } else if (lineWithoutIndentation.isDirectiveMarker()) { + newParagraph(i - 1) + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } else { + if (lineWithoutIndentation.indexOf('|') != -1 && + paragraph.isEmpty() && + (i < 2 || !lines[i - 2].contains("---"))) { + val result = Table.getTable(lines, i - 1, ::lineContent) + if (result != null) { + val (table, nextRow) = result + val content = + if (options.alignTableColumns) { + // Only considering maxLineWidth here, not maxCommentWidth; we + // cannot break table lines, only adjust tabbing, and a padded table + // seems more readable (maxCommentWidth < maxLineWidth is there to + // prevent long lines for readability) + table.format(options.maxLineWidth - indentSize - 3) + } else { + table.original() + } + for (index in content.indices) { + val line = content[index] + appendText(line) + paragraph.separate = index == 0 + paragraph.block = true + paragraph.table = true + newParagraph(-1) + } + i = nextRow + newParagraph(i) + continue + } + } + + // Some common HTML block tags + if (lineWithoutIndentation.startsWith("<") && + (lineWithoutIndentation.startsWith("<p>", true) || + lineWithoutIndentation.startsWith("<p/>", true) || + lineWithoutIndentation.startsWith("<h1", true) || + lineWithoutIndentation.startsWith("<h2", true) || + lineWithoutIndentation.startsWith("<h3", true) || + lineWithoutIndentation.startsWith("<h4", true) || + lineWithoutIndentation.startsWith("<table", true) || + lineWithoutIndentation.startsWith("<tr", true) || + lineWithoutIndentation.startsWith("<caption", true) || + lineWithoutIndentation.startsWith("<td", true) || + lineWithoutIndentation.startsWith("<div", true))) { + newParagraph(i - 1).block = true + if (lineWithoutIndentation.equals("<p>", true) || + lineWithoutIndentation.equals("<p/>", true) || + options.convertMarkup && lineWithoutIndentation.equals("</p>", true)) { + if (options.convertMarkup) { + // Replace <p> with a blank line + paragraph.separate = true + } else { + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } + continue + } else if (lineWithoutIndentation.endsWith("</h1>", true) || + lineWithoutIndentation.endsWith("</h2>", true) || + lineWithoutIndentation.endsWith("</h3>", true) || + lineWithoutIndentation.endsWith("</h4>", true)) { + if (lineWithoutIndentation.startsWith("<h", true) && + options.convertMarkup && + paragraph.isEmpty()) { + paragraph.separate = true + val count = lineWithoutIndentation[lineWithoutIndentation.length - 2] - '0' + for (j in 0 until count.coerceAtLeast(0).coerceAtMost(8)) { + appendText("#") + } + appendText(" ") + appendText(lineWithoutIndentation.substring(4, lineWithoutIndentation.length - 5)) + } else if (options.collapseSpaces) { + appendText(lineWithoutIndentation.collapseSpaces()) + } else { + appendText(lineWithoutIndentation) + } + newParagraph(i).block = true + continue + } + } + + i = addPlainText(i, lineWithoutIndentation) + } + } + + closeParagraph() + arrange() + if (!lineComment) { + punctuate() + } + + return ParagraphList(paragraphs) + } + + private fun addPlainText(i: Int, text: String, braceBalance: Int = 0): Int { + val s = + if (options.convertMarkup && + (text.startsWith("<p>", true) || text.startsWith("<p/>", true))) { + paragraph.separate = true + text.substring(text.indexOf('>') + 1).trim() + } else { + text + } + .let { if (options.collapseSpaces) it.collapseSpaces() else it } + + appendText(s) + appendText(" ") + + if (braceBalance > 0) { + val end = s.indexOf('}') + if (end == -1 && i < lines.size) { + val next = lineContent(lines[i]).trim() + if (breakOutOfTag(next)) { + return i + } + return addPlainText(i + 1, next, 1) + } + } + + val index = s.indexOf("{@") + if (index != -1) { + // find end + val end = s.indexOf('}', index) + if (end == -1 && i < lines.size) { + val next = lineContent(lines[i]).trim() + if (breakOutOfTag(next)) { + return i + } + return addPlainText(i + 1, next, 1) + } + } + + return i + } + + private fun breakOutOfTag(next: String): Boolean { + if (next.isBlank() || next.startsWith("```")) { + // See https://github.com/tnorbye/kdoc-formatter/issues/77 + // There may be comments which look unusual from a formatting + // perspective where it looks like you have embedded markup + // or blank lines; if so, just give up on trying to turn + // this into paragraph text + return true + } + return false + } + + private fun docTagRank(tag: String): Int { + // Canonical kdoc order -- https://kotlinlang.org/docs/kotlin-doc.html#block-tags + // Full list in Dokka's sources: plugins/base/src/main/kotlin/parsers/Parser.kt + return when { + tag.startsWith("@param") -> 0 + tag.startsWith("@return") -> 1 + tag.startsWith("@constructor") -> 2 + tag.startsWith("@receiver") -> 3 + tag.startsWith("@property") -> 4 + tag.startsWith("@throws") -> 5 + tag.startsWith("@exception") -> 6 + tag.startsWith("@sample") -> 7 + tag.startsWith("@see") -> 8 + tag.startsWith("@author") -> 9 + tag.startsWith("@since") -> 10 + tag.startsWith("@suppress") -> 11 + tag.startsWith("@deprecated") -> 12 + else -> 100 // custom tags + } + } + + /** + * Make a pass over the paragraphs and make sure that we (for example) place blank lines around + * preformatted text. + */ + private fun arrange() { + if (paragraphs.isEmpty()) { + return + } + + sortDocTags() + adjustParagraphSeparators() + adjustIndentation() + removeBlankParagraphs() + stripTrailingBlankLines() + } + + private fun sortDocTags() { + if (options.orderDocTags && paragraphs.any { it.doc }) { + val order = paragraphs.mapIndexed { index, paragraph -> paragraph to index }.toMap() + val comparator = + object : Comparator<List<Paragraph>> { + override fun compare(l1: List<Paragraph>, l2: List<Paragraph>): Int { + val p1 = l1.first() + val p2 = l2.first() + val o1 = order[p1]!! + val o2 = order[p2]!! + + // Sort TODOs to the end + if (p1.text.isTodo() != p2.text.isTodo()) { + return if (p1.text.isTodo()) 1 else -1 + } + + if (p1.doc == p2.doc) { + if (p1.doc) { + // Sort @return after @param etc + val r1 = docTagRank(p1.text) + val r2 = docTagRank(p2.text) + if (r1 != r2) { + return r1 - r2 + } + // Within identical tags, preserve current order, except for + // parameter names which are sorted by signature order. + val orderedParameterNames = task.orderedParameterNames + if (orderedParameterNames.isNotEmpty()) { + fun Paragraph.parameterRank(): Int { + val name = text.getParamName() + if (name != null) { + val index = orderedParameterNames.indexOf(name) + if (index != -1) { + return index + } + } + return 1000 + } + + val i1 = p1.parameterRank() + val i2 = p2.parameterRank() + + // If the parameter names are not matching, ignore. + if (i1 != i2) { + return i1 - i2 + } + } + } + return o1 - o2 + } + return if (p1.doc) 1 else -1 + } + } + + // We don't sort the paragraphs list directly; we have to tie all the + // paragraphs following a KDoc parameter to that paragraph (until the + // next KDoc tag). So instead we create a list of lists -- consisting of + // one list for each paragraph, though with a KDoc parameter it's a list + // containing first the KDoc parameter paragraph and then all following + // parameters. We then sort by just the first item in this list of list, + // and then restore the paragraph list from the result. + val units = mutableListOf<List<Paragraph>>() + var tag: MutableList<Paragraph>? = null + for (paragraph in paragraphs) { + if (paragraph.doc) { + tag = mutableListOf() + units.add(tag) + } + if (tag != null && !paragraph.text.isTodo()) { + tag.add(paragraph) + } else { + units.add(listOf(paragraph)) + } + } + units.sortWith(comparator) + + var prev: Paragraph? = null + paragraphs.clear() + for (paragraph in units.flatten()) { + paragraphs.add(paragraph) + prev?.next = paragraph + paragraph.prev = prev + prev = paragraph + } + } + } + + private fun adjustParagraphSeparators() { + var prev: Paragraph? = null + + for (paragraph in paragraphs) { + paragraph.cleanup() + val text = paragraph.text + paragraph.separate = + when { + prev == null -> false + paragraph.preformatted && prev.preformatted -> false + paragraph.table -> + paragraph.separate && (!prev.block || prev.text.isKDocTag() || prev.table) + paragraph.separator || prev.separator -> true + text.isLine(1) || prev.text.isLine(1) -> false + paragraph.separate && paragraph.text.isListItem() -> false + paragraph.separate -> true + // Don't separate kdoc tags, except for the first one + paragraph.doc -> !prev.doc + text.isDirectiveMarker() -> false + text.isTodo() && !prev.text.isTodo() -> true + text.isHeader() -> true + // Set preformatted paragraphs off (but not <pre> tags where it's implicit) + paragraph.preformatted -> + !prev.preformatted && + !text.startsWith("<pre", true) && + (!text.startsWith("```") || !prev.text.isExpectingMore()) + prev.preformatted && prev.text.startsWith("</pre>", true) -> false + paragraph.continuation -> true + paragraph.hanging -> false + paragraph.quoted -> prev.quoted + text.isHeader() -> true + text.startsWith("<p>", true) || text.startsWith("<p/>", true) -> true + else -> !paragraph.block && !paragraph.isEmpty() + } + + if (paragraph.hanging) { + if (paragraph.doc || text.startsWith("<li>", true) || text.isTodo()) { + paragraph.hangingIndent = getIndent(options.hangingIndent) + } else if (paragraph.continuation && paragraph.prev != null) { + paragraph.hangingIndent = paragraph.prev!!.hangingIndent + // Dedent to match hanging indent + val s = paragraph.text.trimStart() + paragraph.content.clear() + paragraph.content.append(s) + } else { + paragraph.hangingIndent = getIndent(text.indexOf(' ') + 1) + } + } + prev = paragraph + } + } + + private fun adjustIndentation() { + val firstIndent = paragraphs[0].originalIndent + if (firstIndent > 0) { + for (paragraph in paragraphs) { + if (paragraph.originalIndent <= firstIndent) { + paragraph.originalIndent = 0 + } + } + } + + // Handle nested lists + var inList = paragraphs.firstOrNull()?.hanging ?: false + var startIndent = 0 + var levels: MutableSet<Int>? = null + for (i in 1 until paragraphs.size) { + val paragraph = paragraphs[i] + if (!inList) { + if (paragraph.hanging) { + inList = true + startIndent = paragraph.originalIndent + } + } else { + if (!paragraph.hanging) { + inList = false + } else { + if (paragraph.originalIndent == startIndent) { + paragraph.originalIndent = 0 + } else if (paragraph.originalIndent > 0) { + (levels ?: mutableSetOf<Int>().also { levels = it }).add(paragraph.originalIndent) + } + } + } + } + + levels?.sorted()?.let { sorted -> + val assignments = mutableMapOf<Int, Int>() + for (i in sorted.indices) { + assignments[sorted[i]] = (i + 1) * options.nestedListIndent + } + for (paragraph in paragraphs) { + if (paragraph.originalIndent > 0) { + val assigned = assignments[paragraph.originalIndent] ?: continue + paragraph.originalIndent = assigned + paragraph.indent = getIndent(paragraph.originalIndent) + } + } + } + } + + private fun removeBlankParagraphs() { + // Remove blank lines between list items and from the end as well as around + // separators + for (i in paragraphs.size - 2 downTo 0) { + if (paragraphs[i].isEmpty() && (!paragraphs[i].preformatted || i == paragraphs.size - 1)) { + paragraphs.removeAt(i) + if (i > 0) { + paragraphs[i - 1].next = null + } + } + } + } + + private fun punctuate() { + if (!options.addPunctuation || paragraphs.isEmpty()) { + return + } + val last = paragraphs.last() + if (last.preformatted || last.doc || last.hanging && !last.continuation || last.isEmpty()) { + return + } + + val text = last.content + if (!text.startsWithUpperCaseLetter()) { + return + } + + for (i in text.length - 1 downTo 0) { + val c = text[i] + if (c.isWhitespace()) { + continue + } + if (c.isLetterOrDigit() || c.isCloseSquareBracket()) { + text.setLength(i + 1) + text.append('.') + } + break + } + } +} + +fun String.containsOnly(vararg s: Char): Boolean { + for (c in this) { + if (s.none { it == c }) { + return false + } + } + return true +} + +fun StringBuilder.startsWithUpperCaseLetter() = + this.isNotEmpty() && this[0].isUpperCase() && this[0].isLetter() + +fun Char.isCloseSquareBracket() = this == ']' diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/Table.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/Table.kt new file mode 100644 index 0000000..a4c837c --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/Table.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +import kotlin.math.max + +class Table( + private val columns: Int, + private val widths: List<Int>, + private val rows: List<Row>, + private val align: List<Align>, + private val original: List<String> +) { + fun original(): List<String> { + return original + } + + /** + * Format the table. Note that table rows cannot be broken into multiple lines in Markdown tables, + * so the [maxWidth] here is used to decide whether to add padding around the table only, and it's + * quite possible for the table to format to wider lengths than [maxWidth]. + */ + fun format(maxWidth: Int = Integer.MAX_VALUE): List<String> { + val tableMaxWidth = + 2 + widths.sumOf { it + 2 } // +2: "| " in each cell and final " |" on the right + + val pad = tableMaxWidth <= maxWidth + val lines = mutableListOf<String>() + for (i in rows.indices) { + val sb = StringBuilder() + val row = rows[i] + for (column in 0 until row.cells.size) { + sb.append('|') + if (pad) { + sb.append(' ') + } + val cell = row.cells[column] + val width = widths[column] + val s = + if (align[column] == Align.CENTER && i > 0) { + String.format( + "%-${width}s", + String.format("%${cell.length + (width - cell.length) / 2}s", cell)) + } else if (align[column] == Align.RIGHT && i > 0) { + String.format("%${width}s", cell) + } else { + String.format("%-${width}s", cell) + } + sb.append(s) + if (pad) { + sb.append(' ') + } + } + sb.append('|') + lines.add(sb.toString()) + sb.clear() + + if (i == 0) { + for (column in 0 until row.cells.size) { + sb.append('|') + var width = widths[column] + if (align[column] != Align.LEFT) { + width-- + if (align[column] == Align.CENTER) { + sb.append(':') + width-- + } + } + if (pad) { + sb.append('-') + } + val s = "-".repeat(width) + sb.append(s) + if (pad) { + sb.append('-') + } + if (align[column] != Align.LEFT) { + sb.append(':') + } + } + sb.append('|') + lines.add(sb.toString()) + sb.clear() + } + } + + return lines + } + + companion object { + /** + * If the line starting at index [start] begins a table, return that table as well as the index + * of the first line after the table. + */ + fun getTable( + lines: List<String>, + start: Int, + lineContent: (String) -> String + ): Pair<Table, Int>? { + if (start > lines.size - 2) { + return null + } + val headerLine = lineContent(lines[start]) + val separatorLine = lineContent(lines[start + 1]) + val barCount = countSeparators(headerLine) + if (!isHeaderDivider(barCount, separatorLine.trim())) { + return null + } + val header = getRow(headerLine) ?: return null + val rows = mutableListOf<Row>() + rows.add(header) + + val dividerRow = getRow(separatorLine) ?: return null + + var i = start + 2 + while (i < lines.size) { + val line = lineContent(lines[i]) + if (!line.contains("|")) { + break + } + val row = getRow(line) ?: break + rows.add(row) + i++ + } + + val rowsAndDivider = rows + dividerRow + if (rowsAndDivider.all { + val first = it.cells.firstOrNull() + first != null && first.isBlank() + }) { + rowsAndDivider.forEach { if (it.cells.isNotEmpty()) it.cells.removeAt(0) } + } + + // val columns = rows.maxOf { it.cells.size } + val columns = dividerRow.cells.size + val maxColumns = rows.maxOf { it.cells.size } + val widths = mutableListOf<Int>() + for (column in 0 until maxColumns) { + widths.add(3) + } + for (row in rows) { + for (column in 0 until row.cells.size) { + widths[column] = max(widths[column], row.cells[column].length) + } + for (column in row.cells.size until columns) { + row.cells.add("") + } + } + + val align = mutableListOf<Align>() + for (cell in dividerRow.cells) { + val direction = + if (cell.endsWith(":")) { + if (cell.startsWith(":-")) { + Align.CENTER + } else { + Align.RIGHT + } + } else { + Align.LEFT + } + align.add(direction) + } + for (column in align.size until maxColumns) { + align.add(Align.LEFT) + } + val table = + Table(columns, widths, rows, align, lines.subList(start, i).map { lineContent(it) }) + return Pair(table, i) + } + + /** Returns true if the given String looks like a markdown table header divider. */ + private fun isHeaderDivider(barCount: Int, s: String): Boolean { + var i = 0 + var count = 0 + while (i < s.length) { + val c = s[i++] + if (c == '\\') { + i++ + } else if (c == '|') { + count++ + } else if (c.isWhitespace() || c == ':') { + continue + } else if (c == '-' && + (s.startsWith("--", i) || + s.startsWith("-:", i) || + i > 1 && s.startsWith(":-:", i - 2) || + i > 1 && s.startsWith(":--", i - 2))) { + while (i < s.length && s[i] == '-') { + i++ + } + } else { + return false + } + } + + return barCount == count + } + + private fun getRow(s: String): Row? { + // Can't just use String.split('|') because that would not handle escaped |'s + if (s.indexOf('|') == -1) { + return null + } + val row = Row() + var i = 0 + var end = 0 + while (end < s.length) { + val c = s[end] + if (c == '\\') { + end++ + } else if (c == '|') { + val cell = s.substring(i, end).trim() + if (end > 0) { + row.cells.add(cell.trim()) + } + i = end + 1 + } + end++ + } + if (end > i) { + val cell = s.substring(i, end).trim() + if (cell.isNotEmpty()) { + row.cells.add(cell.trim()) + } + } + + return row + } + + private fun countSeparators(s: String): Int { + var i = 0 + var count = 0 + while (i < s.length) { + val c = s[i] + if (c == '|') { + count++ + } else if (c == '\\') { + i++ + } + i++ + } + return count + } + } + + enum class Align { + LEFT, + RIGHT, + CENTER + } + + class Row { + val cells = mutableListOf<String>() + } +} diff --git a/core/src/main/java/com/facebook/ktfmt/kdoc/Utilities.kt b/core/src/main/java/com/facebook/ktfmt/kdoc/Utilities.kt new file mode 100644 index 0000000..e034bbe --- /dev/null +++ b/core/src/main/java/com/facebook/ktfmt/kdoc/Utilities.kt @@ -0,0 +1,329 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +import java.util.regex.Pattern +import kotlin.math.min + +fun getIndent(width: Int): String { + val sb = StringBuilder() + for (i in 0 until width) { + sb.append(' ') + } + return sb.toString() +} + +fun getIndentSize(indent: String, options: KDocFormattingOptions): Int { + var size = 0 + for (c in indent) { + if (c == '\t') { + size += options.tabWidth + } else { + size++ + } + } + return size +} + +/** Returns line number (1-based) */ +fun getLineNumber(source: String, offset: Int, startLine: Int = 1, startOffset: Int = 0): Int { + var line = startLine + for (i in startOffset until offset) { + val c = source[i] + if (c == '\n') { + line++ + } + } + return line +} + +private val numberPattern = Pattern.compile("^\\d+([.)]) ") + +fun String.isListItem(): Boolean { + return startsWith("- ") || + startsWith("* ") || + startsWith("+ ") || + firstOrNull()?.isDigit() == true && numberPattern.matcher(this).find() || + startsWith("<li>", ignoreCase = true) +} + +fun String.collapseSpaces(): String { + if (indexOf(" ") == -1) { + return this.trimEnd() + } + val sb = StringBuilder() + var prev: Char = this[0] + for (i in indices) { + if (prev == ' ') { + if (this[i] == ' ') { + continue + } + } + sb.append(this[i]) + prev = this[i] + } + return sb.trimEnd().toString() +} + +fun String.isTodo(): Boolean { + return startsWith("TODO:") || startsWith("TODO(") +} + +fun String.isHeader(): Boolean { + return startsWith("#") || startsWith("<h", true) +} + +fun String.isQuoted(): Boolean { + return startsWith("> ") +} + +fun String.isDirectiveMarker(): Boolean { + return startsWith("<!--") || startsWith("-->") +} + +/** + * Returns true if the string ends with a symbol that implies more text is coming, e.g. ":" or "," + */ +fun String.isExpectingMore(): Boolean { + val last = lastOrNull { !it.isWhitespace() } ?: return false + return last == ':' || last == ',' +} + +/** + * Does this String represent a divider line? (Markdown also requires it to be surrounded by empty + * lines which has to be checked by the caller) + */ +fun String.isLine(minCount: Int = 3): Boolean { + return startsWith('-') && containsOnly('-', ' ') && count { it == '-' } >= minCount || + startsWith('_') && containsOnly('_', ' ') && count { it == '_' } >= minCount +} + +fun String.isKDocTag(): Boolean { + // Not using a hardcoded list here since tags can change over time + if (startsWith("@")) { + for (i in 1 until length) { + val c = this[i] + if (c.isWhitespace()) { + return i > 2 + } else if (!c.isLetter() || !c.isLowerCase()) { + if (c == '[' && startsWith("@param")) { + // @param is allowed to use brackets -- see + // https://kotlinlang.org/docs/kotlin-doc.html#param-name + // Example: @param[foo] The description of foo + return true + } + return false + } + } + return true + } + return false +} + +/** + * If this String represents a KDoc `@param` tag, returns the corresponding parameter name, + * otherwise null. + */ +fun String.getParamName(): String? { + val length = this.length + var start = 0 + while (start < length && this[start].isWhitespace()) { + start++ + } + if (!this.startsWith("@param", start)) { + return null + } + start += "@param".length + + while (start < length) { + if (this[start].isWhitespace()) { + start++ + } else { + break + } + } + + if (start < length && this[start] == '[') { + start++ + while (start < length) { + if (this[start].isWhitespace()) { + start++ + } else { + break + } + } + } + + var end = start + while (end < length) { + if (!this[end].isJavaIdentifierPart()) { + break + } + end++ + } + + if (end > start) { + return this.substring(start, end) + } + + return null +} + +private fun getIndent(start: Int, lookup: (Int) -> Char): String { + var i = start - 1 + while (i >= 0 && lookup(i) != '\n') { + i-- + } + val sb = StringBuilder() + for (j in i + 1 until start) { + sb.append(lookup(j)) + } + return sb.toString() +} + +/** + * Given a character [lookup] function in a document of [max] characters, for a comment starting at + * offset [start], compute the effective indent on the first line and on subsequent lines. + * + * For a comment starting on its own line, the two will be the same. But for a comment that is at + * the end of a line containing code, the first line indent will not be the indentation of the + * earlier code, it will be the full indent as if all the code characters were whitespace characters + * (which lets the formatter figure out how much space is available on the first line). + */ +fun computeIndents(start: Int, lookup: (Int) -> Char, max: Int): Pair<String, String> { + val originalIndent = getIndent(start, lookup) + val suffix = !originalIndent.all { it.isWhitespace() } + val indent = + if (suffix) { + originalIndent.map { if (it.isWhitespace()) it else ' ' }.joinToString(separator = "") + } else { + originalIndent + } + + val secondaryIndent = + if (suffix) { + // We don't have great heuristics to figure out what the indent should be + // following a source line -- e.g. it can be implied by things like whether + // the line ends with '{' or an operator, but it's more complicated than + // that. So we'll cheat and just look to see what the existing code does! + var offset = start + while (offset < max && lookup(offset) != '\n') { + offset++ + } + offset++ + val sb = StringBuilder() + while (offset < max) { + if (lookup(offset) == '\n') { + sb.clear() + } else { + val c = lookup(offset) + if (c.isWhitespace()) { + sb.append(c) + } else { + if (c == '*') { + // in a comment, the * is often one space indented + // to line up with the first * in the opening /** and + // the actual indent should be aligned with the / + sb.setLength(sb.length - 1) + } + break + } + } + offset++ + } + sb.toString() + } else { + originalIndent + } + + return Pair(indent, secondaryIndent) +} + +/** + * Attempt to preserve the caret position across reformatting. Returns the delta in the new comment. + */ +fun findSamePosition(comment: String, delta: Int, reformattedComment: String): Int { + // First see if the two comments are identical up to the delta; if so, same + // new position + for (i in 0 until min(comment.length, reformattedComment.length)) { + if (i == delta) { + return delta + } else if (comment[i] != reformattedComment[i]) { + break + } + } + + var i = comment.length - 1 + var j = reformattedComment.length - 1 + if (delta == i + 1) { + return j + 1 + } + while (i >= 0 && j >= 0) { + if (i == delta) { + return j + } + if (comment[i] != reformattedComment[j]) { + break + } + i-- + j-- + } + + fun isSignificantChar(c: Char): Boolean = c.isWhitespace() || c == '*' + + // Finally it's somewhere in the middle; search by character skipping over + // insignificant characters (space, *, etc) + fun nextSignificantChar(s: String, from: Int): Int { + var curr = from + while (curr < s.length) { + val c = s[curr] + if (isSignificantChar(c)) { + curr++ + } else { + break + } + } + return curr + } + + var offset = 0 + var reformattedOffset = 0 + while (offset < delta && reformattedOffset < reformattedComment.length) { + offset = nextSignificantChar(comment, offset) + reformattedOffset = nextSignificantChar(reformattedComment, reformattedOffset) + if (offset == delta) { + return reformattedOffset + } + offset++ + reformattedOffset++ + } + return reformattedOffset +} + +// Until stdlib version is no longer experimental +fun <T, R : Comparable<R>> Iterable<T>.maxOf(selector: (T) -> R): R { + val iterator = iterator() + if (!iterator.hasNext()) throw NoSuchElementException() + var maxValue = selector(iterator.next()) + while (iterator.hasNext()) { + val v = selector(iterator.next()) + if (maxValue < v) { + maxValue = v + } + } + return maxValue +} diff --git a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt index c02aca6..3697d72 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/MainTest.kt @@ -123,6 +123,21 @@ class MainTest { } @Test + fun `Parsing errors are reported (stdin-name)`() { + val code = "fun f1 ( " + val returnValue = + Main( + code.byteInputStream(), + PrintStream(out), + PrintStream(err), + arrayOf("--stdin-name=file/Foo.kt", "-")) + .run() + + assertThat(returnValue).isEqualTo(1) + assertThat(err.toString("UTF-8")).startsWith("file/Foo.kt:1:14: error: ") + } + + @Test fun `Parsing errors are reported (file)`() { val fooBar = root.resolve("foo.kt") fooBar.writeText("fun f1 ( ") @@ -231,7 +246,8 @@ class MainTest { |println(child) |} |} - |""".trimMargin() + |""" + .trimMargin() val formatted = """fun f() { | for (child in @@ -239,7 +255,8 @@ class MainTest { | println(child) | } |} - |""".trimMargin() + |""" + .trimMargin() Main( code.byteInputStream(), PrintStream(out), @@ -429,4 +446,24 @@ class MainTest { assertThat(out.toString("UTF-8")).isEqualTo("<stdin>\n") assertThat(exitCode).isEqualTo(1) } + + @Test + fun `--stdin-name can only be used with stdin`() { + val code = """fun f () = println( "hello, world" )""" + val file = root.resolve("foo.kt") + file.writeText(code) + + val exitCode = + Main( + emptyInput, + PrintStream(out), + PrintStream(err), + arrayOf("--stdin-name=bar.kt", file.toString())) + .run() + + assertThat(file.readText()).isEqualTo(code) + assertThat(out.toString("UTF-8")).isEmpty() + assertThat(err.toString("UTF-8")).isEqualTo("Error: --stdin-name can only be used with stdin\n") + assertThat(exitCode).isEqualTo(1) + } } diff --git a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt index 40f3646..37cbf57 100644 --- a/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/cli/ParsedArgsTest.kt @@ -42,29 +42,23 @@ class ParsedArgsTest { @Test fun `files to format are returned and unknown flags are reported`() { - val out = ByteArrayOutputStream() - - val (fileNames, _) = ParsedArgs.parseOptions(PrintStream(out), arrayOf("foo.kt", "--unknown")) + val (parsed, out) = parseTestOptions("foo.kt", "--unknown") - assertThat(fileNames).containsExactly("foo.kt") + assertThat(parsed.fileNames).containsExactly("foo.kt") assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n") } @Test fun `files to format are returned and flags starting with @ are reported`() { - val out = ByteArrayOutputStream() + val (parsed, out) = parseTestOptions("foo.kt", "@unknown") - val (fileNames, _) = ParsedArgs.parseOptions(PrintStream(out), arrayOf("foo.kt", "@unknown")) - - assertThat(fileNames).containsExactly("foo.kt") + assertThat(parsed.fileNames).containsExactly("foo.kt") assertThat(out.toString()).isEqualTo("Unexpected option: @unknown\n") } @Test fun `parseOptions uses default values when args are empty`() { - val out = ByteArrayOutputStream() - - val parsed = ParsedArgs.parseOptions(PrintStream(out), arrayOf("foo.kt")) + val (parsed, _) = parseTestOptions("foo.kt") val formattingOptions = parsed.formattingOptions assertThat(formattingOptions.style).isEqualTo(FormattingOptions.Style.FACEBOOK) @@ -76,57 +70,67 @@ class ParsedArgsTest { assertThat(parsed.dryRun).isFalse() assertThat(parsed.setExitIfChanged).isFalse() + assertThat(parsed.stdinName).isNull() } @Test fun `parseOptions recognizes --dropbox-style and rejects unknown flags`() { - val out = ByteArrayOutputStream() + val (parsed, out) = parseTestOptions("--dropbox-style", "foo.kt", "--unknown") - val (fileNames, formattingOptions) = - ParsedArgs.parseOptions(PrintStream(out), arrayOf("--dropbox-style", "foo.kt", "--unknown")) - - assertThat(fileNames).containsExactly("foo.kt") - assertThat(formattingOptions.blockIndent).isEqualTo(4) - assertThat(formattingOptions.continuationIndent).isEqualTo(4) + assertThat(parsed.fileNames).containsExactly("foo.kt") + assertThat(parsed.formattingOptions.blockIndent).isEqualTo(4) + assertThat(parsed.formattingOptions.continuationIndent).isEqualTo(4) assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n") } @Test fun `parseOptions recognizes --google-style`() { - val out = ByteArrayOutputStream() - - val (_, formattingOptions) = - ParsedArgs.parseOptions(PrintStream(out), arrayOf("--google-style", "foo.kt")) - - assertThat(formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT) + val (parsed, _) = parseTestOptions("--google-style", "foo.kt") + assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT) } @Test fun `parseOptions recognizes --dry-run`() { - val out = ByteArrayOutputStream() - - val parsed = ParsedArgs.parseOptions(PrintStream(out), arrayOf("--dry-run", "foo.kt")) - + val (parsed, _) = parseTestOptions("--dry-run", "foo.kt") assertThat(parsed.dryRun).isTrue() } @Test fun `parseOptions recognizes -n as --dry-run`() { - val out = ByteArrayOutputStream() - - val parsed = ParsedArgs.parseOptions(PrintStream(out), arrayOf("-n", "foo.kt")) - + val (parsed, _) = parseTestOptions("-n", "foo.kt") assertThat(parsed.dryRun).isTrue() } @Test fun `parseOptions recognizes --set-exit-if-changed`() { - val out = ByteArrayOutputStream() + val (parsed, _) = parseTestOptions("--set-exit-if-changed", "foo.kt") + assertThat(parsed.setExitIfChanged).isTrue() + } - val parsed = - ParsedArgs.parseOptions(PrintStream(out), arrayOf("--set-exit-if-changed", "foo.kt")) + @Test + fun `parseOptions --stdin-name`() { + val (parsed, _) = parseTestOptions("--stdin-name=my/foo.kt") + assertThat(parsed.stdinName).isEqualTo("my/foo.kt") + } - assertThat(parsed.setExitIfChanged).isTrue() + @Test + fun `parseOptions --stdin-name with empty value`() { + val (parsed, _) = parseTestOptions("--stdin-name=") + assertThat(parsed.stdinName).isEqualTo("") + } + + @Test + fun `parseOptions --stdin-name without value`() { + val (parsed, out) = parseTestOptions("--stdin-name") + assertThat(out).isEqualTo("Found option '--stdin-name', expected '--stdin-name=<value>'\n") + assertThat(parsed.stdinName).isNull() + } + + @Test + fun `parseOptions --stdin-name prefix`() { + val (parsed, out) = parseTestOptions("--stdin-namea") + assertThat(out).isEqualTo("Found option '--stdin-namea', expected '--stdin-name=<value>'\n") + assertThat(parsed.stdinName).isNull() } @Test @@ -153,4 +157,9 @@ class ParsedArgsTest { assertThat(parsed.setExitIfChanged).isTrue() assertThat(parsed.fileNames).containsExactlyElementsIn(listOf("File1.kt", "File2.kt")) } + + private fun parseTestOptions(vararg args: String): Pair<ParsedArgs, String> { + val out = ByteArrayOutputStream() + return Pair(ParsedArgs.parseOptions(PrintStream(out), arrayOf(*args)), out.toString()) + } } diff --git a/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt b/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt index 2096f50..88b16fe 100644 --- a/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt @@ -47,7 +47,8 @@ class FormatterTest { |println("Called with args:") | |args.forEach { println(File + "-") } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `support script (kts) files with a shebang`() = @@ -57,7 +58,8 @@ class FormatterTest { |package foo | |println("Called") - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `call chains`() = @@ -94,7 +96,8 @@ class FormatterTest { | .add(1) | .build() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -116,7 +119,8 @@ class FormatterTest { | doc.computeBreaks( | output.commentsHelper, maxWidth, State(0)) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -139,7 +143,8 @@ class FormatterTest { |) { | val a = 0 |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -170,7 +175,8 @@ class FormatterTest { | | ImmutableList.newBuilder().add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).build() | } - |""".trimMargin() + |""" + .trimMargin() val expected = """ @@ -211,7 +217,8 @@ class FormatterTest { | .add(1) | .build() |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) // Don't add more tests here @@ -225,7 +232,8 @@ class FormatterTest { | var x: Int = 4 | val y = 0 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `class without a body nor properties`() = assertFormatted("class Foo\n") @@ -239,7 +247,8 @@ class FormatterTest { """fun interface MyRunnable { | fun runIt() |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle complex fun interface without body`() = @@ -258,7 +267,8 @@ class FormatterTest { | fun method() {} | class Bar |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `properties and fields with modifiers`() = @@ -270,7 +280,8 @@ class FormatterTest { | open var f3 = 0 | final var f4 = 0 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `properties with multiple modifiers`() = @@ -279,7 +290,8 @@ class FormatterTest { |class Foo(public open inner val p1: Int) { | public open inner var f2 = 0 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `spaces around binary operations`() = @@ -289,7 +301,8 @@ class FormatterTest { | a = 5 | x + 1 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `breaking long binary operations`() = @@ -310,7 +323,8 @@ class FormatterTest { | value8) + | value9 |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -322,7 +336,8 @@ class FormatterTest { | return expression1 != expression2 || | expression2 != expression1 |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -341,7 +356,8 @@ class FormatterTest { | "lazy" + | "dog" |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -358,7 +374,8 @@ class FormatterTest { | "over" + | "the".."lazy" + "dog" |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -413,7 +430,8 @@ class FormatterTest { | // | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -424,7 +442,8 @@ class FormatterTest { |// a | |/* Another comment */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `properties with accessors`() = @@ -445,7 +464,8 @@ class FormatterTest { | var zz = false | private set |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `properties with accessors and semicolons on same line`() { @@ -456,7 +476,8 @@ class FormatterTest { | internal val a by lazy { 5 }; internal get | var foo: Int; get() = 6; set(x) {}; |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ @@ -469,7 +490,8 @@ class FormatterTest { | get() = 6 | set(x) {} |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -485,7 +507,8 @@ class FormatterTest { | "Hello there this is long" | get() = field |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -498,7 +521,8 @@ class FormatterTest { | a++ | a === b |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `package names stay in one line`() { @@ -507,13 +531,15 @@ class FormatterTest { | package com .example. subexample | |fun f() = 1 - |""".trimMargin() + |""" + .trimMargin() val expected = """ |package com.example.subexample | |fun f() = 1 - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -527,15 +553,18 @@ class FormatterTest { |import `nothing stops`.`us`.`from doing this` | |fun f() = `from doing this`() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `safe dot operator expression`() = - assertFormatted(""" + assertFormatted( + """ |fun f() { | node?.name |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `safe dot operator expression with normal`() = @@ -544,7 +573,8 @@ class FormatterTest { |fun f() { | node?.name.hello |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `safe dot operator expression chain in expression function`() = @@ -553,7 +583,8 @@ class FormatterTest { |-------------------------------------------------- |fun f(number: Int) = | Something.doStuff(number)?.size - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -575,7 +606,8 @@ class FormatterTest { | foo.facebook.Foo | .format() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -626,7 +658,8 @@ class FormatterTest { | .methodName4() | .abcdefghijkl() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -703,7 +736,8 @@ class FormatterTest { | foo | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -725,27 +759,27 @@ class FormatterTest { | } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test - fun `don't one-line lambdas following parameter breaks`() = + fun `don't one-line lambdas following argument breaks`() = assertFormatted( """ |------------------------------------------------------------------------ |class Foo : Bar() { | fun doIt() { - | // don't break in lambda, no parameter breaks found + | // don't break in lambda, no argument breaks found | fruit.forEach { eat(it) } | - | // break in the lambda because the closing paren gets attached - | // to the last argument + | // break in the lambda, without comma | fruit.forEach( | someVeryLongParameterNameThatWillCauseABreak, | evenWithoutATrailingCommaOnTheParameterListSoLetsSeeIt) { - | eat(it) - | } + | eat(it) + | } | - | // break in the lambda + | // break in the lambda, with comma | fruit.forEach( | fromTheVine = true, | ) { @@ -782,7 +816,8 @@ class FormatterTest { | } | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -800,26 +835,106 @@ class FormatterTest { | } | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test - fun `no break between multi-line strings and their selectors`() = + fun `no forward propagation of breaks in call expressions (at trailing lambda)`() = + assertFormatted( + """ + |-------------------------- + |fun test() { + | foo_bar_baz__zip<A>(b) { + | c + | } + | foo.bar(baz).zip<A>(b) { + | c + | } + |} + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `forward propagation of breaks in call expressions (at value args)`() = + assertFormatted( + """ + |---------------------- + |fun test() { + | foo_bar_baz__zip<A>( + | b) { + | c + | } + |} + | + |fun test() { + | foo.bar(baz).zip<A>( + | b) { + | c + | } + |} + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `forward propagation of breaks in call expressions (at type args)`() = + assertFormatted( + """ + |------------------- + |fun test() { + | foo_bar_baz__zip< + | A>( + | b) { + | c + | } + | foo.bar(baz).zip< + | A>( + | b) { + | c + | } + |} + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `expected indent in methods following single-line strings`() = assertFormatted( """ |------------------------- + |"Hello %s".format( + | someLongExpression) + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `forced break between multi-line strings and their selectors`() = + assertFormatted( + """ + |------------------------- + |val STRING = + | $TQ + | |foo + | |$TQ + | .wouldFit() + | |val STRING = - | ""${'"'} + | $TQ | |foo - | |""${'"'}.trimMargin() + | |----------------------------------$TQ + | .wouldntFit() | - |// This is a bug (line is longer than limit) - |// that we don't know how to avoid, for now. |val STRING = - | ""${'"'} + | $TQ | |foo - | |----------------------------------""${'"'}.trimMargin() - |""".trimMargin(), + | |$TQ + | .firstLink() + | .secondLink() + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -834,7 +949,8 @@ class FormatterTest { | test */ | |val x = FooBar.def { foosBars(bar) } - |""".trimMargin() + |""" + .trimMargin() val expected = """ |import abc.def /* @@ -845,7 +961,8 @@ class FormatterTest { |import foo.bar // Test | |val x = FooBar.def { foosBars(bar) } - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -855,7 +972,8 @@ class FormatterTest { """ |import com.example.zab // test |import com.example.foo ; val x = Sample(foo, zab) - |""".trimMargin() + |""" + .trimMargin() val expected = """ @@ -863,7 +981,8 @@ class FormatterTest { |import com.example.zab // test | |val x = Sample(foo, zab) - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -878,7 +997,8 @@ class FormatterTest { |import com.example.wow | |val x = `if` { we.`when`(wow) } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `backticks are ignored in import sort order ('as' directory)`() = @@ -890,7 +1010,8 @@ class FormatterTest { |import com.example.a as wow | |val x = `if` { we.`when`(wow) } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `imports are deduplicated`() { @@ -905,7 +1026,8 @@ class FormatterTest { |import com.example.a as `when` | |val x = `if` { we.`when`(wow) } ?: b - |""".trimMargin() + |""" + .trimMargin() val expected = """ |import com.example.a as `if` @@ -916,7 +1038,8 @@ class FormatterTest { |import com.example.b.* | |val x = `if` { we.`when`(wow) } ?: b - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -939,7 +1062,8 @@ class FormatterTest { | `if` { bar } | val x = unused() |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |import com.used.FooBarBaz as Baz @@ -954,7 +1078,8 @@ class FormatterTest { | `if` { bar } | val x = unused() |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -971,7 +1096,8 @@ class FormatterTest { |fun test() { | foo(CONSTANT, Sample()) |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |package com.example @@ -982,7 +1108,8 @@ class FormatterTest { |fun test() { | foo(CONSTANT, Sample()) |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -1019,7 +1146,8 @@ class FormatterTest { | * @throws AnException | */ |class Dummy - |""".trimMargin() + |""" + .trimMargin() val expected = """ |package com.example.kdoc @@ -1038,21 +1166,55 @@ class FormatterTest { | * | * Old {@link JavaDocLink} that gets removed. | * - | * @throws AnException - | * @exception Sample.SampleException | * @param unused [Param] - | * @property JavaDocLink [Param] | * @return [Unit] as [ReturnedValue] + | * @property JavaDocLink [Param] + | * @throws AnException + | * @throws AnException + | * @exception Sample.SampleException | * @sample Example | * @see Bar for more info - | * @throws AnException | */ |class Dummy - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @Test + fun `keep import elements only mentioned in kdoc, single line`() { + assertFormatted( + """ + |import com.shopping.Bag + | + |/** + | * Some summary. + | * + | * @param count you can fit this many in a [Bag] + | */ + |fun fetchBananas(count: Int) + |""" + .trimMargin()) + } + + @Test + fun `keep import elements only mentioned in kdoc, multiline`() { + assertFormatted( + """ + |import com.shopping.Bag + | + |/** + | * Some summary. + | * + | * @param count this is how many of these wonderful fruit you can fit into the useful object that + | * you may refer to as a [Bag] + | */ + |fun fetchBananas(count: Int) + |""" + .trimMargin()) + } + + @Test fun `keep component imports`() = assertFormatted( """ @@ -1063,7 +1225,8 @@ class FormatterTest { |import com.example.component3 |import com.example.component4 |import com.example.component5 - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `keep operator imports`() = @@ -1101,7 +1264,8 @@ class FormatterTest { |import com.example.timesAssign |import com.example.unaryMinus |import com.example.unaryPlus - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `keep unused imports when formatting options has feature turned off`() { @@ -1116,7 +1280,8 @@ class FormatterTest { |import com.unused.b as we |import com.unused.bar // test |import com.unused.`class` - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code) .withOptions(FormattingOptions(removeUnusedImports = false)) @@ -1138,7 +1303,8 @@ class FormatterTest { |// trailing comment | |val x = Sample(abc, bcd) - |""".trimMargin() + |""" + .trimMargin() val expected = """ |package com.facebook.ktfmt @@ -1153,7 +1319,8 @@ class FormatterTest { |// trailing comment | |val x = Sample(abc, bcd) - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -1166,7 +1333,8 @@ class FormatterTest { |/* |bar |*/ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `basic annotations`() = @@ -1179,7 +1347,8 @@ class FormatterTest { | @Fancy val a = 1 + foo | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `function calls with multiple arguments`() = @@ -1193,7 +1362,8 @@ class FormatterTest { | 123456789012345678901234567890, | 123456789012345678901234567890) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `function calls with multiple named arguments`() = @@ -1207,7 +1377,8 @@ class FormatterTest { | b = 23456789012345678901234567890, | c = 3456789012345678901234567890) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `named arguments indent their value expression`() = @@ -1221,7 +1392,8 @@ class FormatterTest { | print() | }, | duration = duration) - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `Arguments are blocks`() = @@ -1247,7 +1419,8 @@ class FormatterTest { | initializer = property.initializer) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1260,7 +1433,8 @@ class FormatterTest { | println(number) | }) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `anonymous function with receiver`() = @@ -1272,7 +1446,8 @@ class FormatterTest { | println(this) | }) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() with a subject expression`() = @@ -1290,7 +1465,8 @@ class FormatterTest { | } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with complex predicates`() = @@ -1305,7 +1481,8 @@ class FormatterTest { | } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with several conditions`() = @@ -1318,7 +1495,8 @@ class FormatterTest { | else -> print(0) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with is and in`() = @@ -1336,7 +1514,8 @@ class FormatterTest { | else -> print(3) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with enum values`() = @@ -1349,7 +1528,8 @@ class FormatterTest { | else -> print(3) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with generic matcher and exhaustive`() = @@ -1361,7 +1541,8 @@ class FormatterTest { | is Failure -> print(2) | }.exhaustive |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression with multiline condition`() = @@ -1384,7 +1565,8 @@ class FormatterTest { | 2 -> print(2) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1402,7 +1584,8 @@ class FormatterTest { | doItOnce() | doItTwice() |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `when() expression storing in local variable`() = @@ -1414,7 +1597,8 @@ class FormatterTest { | is Failure -> print(2) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `line breaks inside when expressions and conditions`() = @@ -1435,7 +1619,8 @@ class FormatterTest { | } | .build() |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `function return types`() = @@ -1444,7 +1629,8 @@ class FormatterTest { |fun f1(): Int = 0 | |fun f2(): Int {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `multi line function without a block body`() = @@ -1457,7 +1643,8 @@ class FormatterTest { | |fun shortFun(): Int = | 1234567 + 1234567 - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1480,7 +1667,8 @@ class FormatterTest { | // | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1496,7 +1684,8 @@ class FormatterTest { |class Derived4 : Super1() | |class Derived5 : Super3<Int>() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `list of superclasses over multiple lines`() = @@ -1519,20 +1708,25 @@ class FormatterTest { | |class Derived5 : | Super3<Int>() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test fun `annotations with parameters`() = - assertFormatted(""" + assertFormatted( + """ |@AnnWithArrayValue(1, 2, 3) class C - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `method modifiers`() = - assertFormatted(""" + assertFormatted( + """ |override internal fun f() {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `class modifiers`() = @@ -1545,7 +1739,8 @@ class FormatterTest { |final class Foo | |open class Foo - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `kdoc comments`() { @@ -1556,11 +1751,14 @@ class FormatterTest { | */ class F { | | } - |""".trimMargin() - val expected = """ + |""" + .trimMargin() + val expected = + """ |/** foo */ |class F {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -1573,11 +1771,14 @@ class FormatterTest { | */ class F { | | } - |""".trimMargin() - val expected = """ + |""" + .trimMargin() + val expected = + """ |/** foo /* bla */ */ |class F {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -1591,7 +1792,8 @@ class FormatterTest { | * ``` | */ |fun foo() {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `formatting kdoc doesn't add p HTML tags`() = @@ -1605,7 +1807,8 @@ class FormatterTest { | * | * <p>On the other hand, we respect existing tags, and don't remove them. | */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `formatting kdoc preserves lists`() = @@ -1618,7 +1821,8 @@ class FormatterTest { | * | * This is another paragraph | */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `formatting kdoc lists with line wraps breaks and merges correctly`() { @@ -1632,66 +1836,20 @@ class FormatterTest { | * | * This is another paragraph | */ - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** | * Here are some fruit I like: | * - Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana - | * Banana Banana Banana Banana Banana + | * Banana Banana Banana Banana Banana | * - Apple Apple Apple Apple Apple Apple | * | * This is another paragraph | */ - |""".trimMargin() - assertThatFormatting(code).isEqualTo(expected) - } - - @Test - fun `too many spaces on list continuation mean it's a code block, so mark it accordingly`() { - val code = - """ - |/** - | * Here are some fruit I like: - | * - Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana - | * Banana Banana Banana Banana Banana - | */ - |""".trimMargin() - val expected = - """ - |/** - | * Here are some fruit I like: - | * - Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana Banana - | * ``` - | * Banana Banana Banana Banana Banana - | * ``` - | */ - |""".trimMargin() - assertThatFormatting(code).isEqualTo(expected) - } - - @Test - fun `add explicit code markers around indented code`() { - val code = - """ - |/** - | * This is a code example: - | * - | * this_is_code() - | * - | * This is not code again - | */ - |""".trimMargin() - val expected = - """ - |/** - | * This is a code example: - | * ``` - | * this_is_code() - | * ``` - | * This is not code again - | */ - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -1706,7 +1864,8 @@ class FormatterTest { | * | * This is another paragraph | */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `formatting kdoc preserves numbered`() = @@ -1719,7 +1878,8 @@ class FormatterTest { | * | * This is another paragraph | */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `formatting kdoc with markdown errors`() = @@ -1727,7 +1887,8 @@ class FormatterTest { """ |/** \[ */ |fun markdownError() = Unit - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `return statement with value`() = @@ -1736,7 +1897,8 @@ class FormatterTest { |fun random(): Int { | return 4 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `return statement without value`() = @@ -1746,7 +1908,8 @@ class FormatterTest { | print(b) | return |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `return expression without value`() = @@ -1755,7 +1918,8 @@ class FormatterTest { |fun print(b: Boolean?) { | print(b ?: return) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `if statement without else`() = @@ -1766,7 +1930,8 @@ class FormatterTest { | println(b) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `if statement with else`() = @@ -1779,7 +1944,8 @@ class FormatterTest { | println(1) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `if expression with else`() = @@ -1794,7 +1960,8 @@ class FormatterTest { | } else 2) | return if (b) 1 else 2 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `if expression with break before else`() = @@ -1808,7 +1975,8 @@ class FormatterTest { | return if (a + b < 20) a + b | else c |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1827,7 +1995,8 @@ class FormatterTest { | a + b | else c |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1845,7 +2014,8 @@ class FormatterTest { | println("Everything is okay") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `if expression with multiline condition`() = @@ -1866,7 +2036,8 @@ class FormatterTest { | bar() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1879,14 +2050,17 @@ class FormatterTest { | myVariable = | function1(4, 60, 8) + function2(57, 39, 20) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test fun `A program that tickled a bug in KotlinInput`() = - assertFormatted(""" + assertFormatted( + """ |val x = 2 - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `a few variations of constructors`() = @@ -1910,7 +2084,8 @@ class FormatterTest { | number5: Int, | number6: Int |) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `a primary constructor without a class body `() = @@ -1920,7 +2095,8 @@ class FormatterTest { |data class Foo( | val number: Int = 0 |) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1933,7 +2109,8 @@ class FormatterTest { | val number: Int = 0 | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1946,7 +2123,8 @@ class FormatterTest { | val number: Int = 0 | ) {} |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -1960,7 +2138,8 @@ class FormatterTest { | val title: String, | val offspring2: List<Foo> |) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `a constructor with keyword and many arguments over breaking to next line`() = @@ -1974,7 +2153,8 @@ class FormatterTest { | val offspring: List<Foo>, | val foo: String |) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `a constructor with many arguments over multiple lines`() = @@ -1989,7 +2169,8 @@ class FormatterTest { | val title: String, | val offspring: List<Foo> |) {} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2002,7 +2183,8 @@ class FormatterTest { | println("built") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `a secondary constructor with many arguments over multiple lines`() = @@ -2018,7 +2200,8 @@ class FormatterTest { | val offspring: List<Foo> | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2041,7 +2224,8 @@ class FormatterTest { | offspring, | offspring) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2056,7 +2240,8 @@ class FormatterTest { | Foo.createSpeciallyDesignedParameter(), | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2073,7 +2258,8 @@ class FormatterTest { | init(attrs) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle calling super constructor in secondary constructor`() = @@ -2082,7 +2268,8 @@ class FormatterTest { |class Foo : Bar { | internal constructor(number: Int) : super(number) {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle super statement with with type argument`() = @@ -2093,7 +2280,8 @@ class FormatterTest { | super<FooBar>.doIt() | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle super statement with with label argument`() = @@ -2109,7 +2297,8 @@ class FormatterTest { | } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `primary constructor without parameters with a KDoc`() = @@ -2118,12 +2307,16 @@ class FormatterTest { |class Class |/** A comment */ |constructor() {} - |""".trimMargin()) + |""" + .trimMargin()) @Test - fun `handle objects`() = assertFormatted(""" + fun `handle objects`() = + assertFormatted( + """ |object Foo(n: Int) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle object expression`() = @@ -2132,7 +2325,8 @@ class FormatterTest { |fun f(): Any { | return object : Adapter() {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle object expression in parenthesis`() = @@ -2141,7 +2335,8 @@ class FormatterTest { |fun f(): Any { | return (object : Adapter() {}) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle array indexing operator`() = @@ -2151,7 +2346,8 @@ class FormatterTest { | a[3] | b[3, 4] |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `keep array indexing grouped with expression is possible`() = @@ -2172,7 +2368,8 @@ class FormatterTest { | .foobar[1, 2, 3] | .barfoo[3, 2, 1] |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2194,7 +2391,8 @@ class FormatterTest { | .foobar[1, 2, 3] | .barfoo[3, 2, 1] |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2211,7 +2409,8 @@ class FormatterTest { | oneTwoThreeFourFiveSixSeven( | foo, bar, zed, boo) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test fun `chains with derferences and array indexing`() = @@ -2227,7 +2426,8 @@ class FormatterTest { | .feep[1] | as Boo |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2242,7 +2442,8 @@ class FormatterTest { | println(it) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2255,7 +2456,8 @@ class FormatterTest { | println(it) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2281,7 +2483,8 @@ class FormatterTest { | FooFoo.foooooooo() | .foooooooo() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2298,7 +2501,8 @@ class FormatterTest { | .someItems[0] | .doIt() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2313,7 +2517,8 @@ class FormatterTest { | somePropertiesProvider, somePropertyCallbacks] | .also { _somePropertyWithBackingOne = it } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `array access in middle of chain and end of it behaves similarly`() = @@ -2328,7 +2533,8 @@ class FormatterTest { | println() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2340,13 +2546,16 @@ class FormatterTest { |} | |fun doItWithNulls(a: String, b: String?) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `nullable function type`() = - assertFormatted(""" + assertFormatted( + """ |var listener: ((Boolean) -> Unit)? = null - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `redundant parenthesis in function types`() = @@ -2355,7 +2564,8 @@ class FormatterTest { |val a: (Int) = 7 | |var listener: ((Boolean) -> Unit) = foo - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle string literals`() = @@ -2366,7 +2576,8 @@ class FormatterTest { | println("Hello! ${'$'}world") | println("Hello! ${'$'}{"wor" + "ld"}") |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle multiline string literals`() = @@ -2376,7 +2587,8 @@ class FormatterTest { | println(${"\"".repeat(3)}Hello | world!${"\"".repeat(3)}) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `Trailing whitespaces are preserved in multiline strings`() { @@ -2401,13 +2613,14 @@ class FormatterTest { fun `Consecutive line breaks in multiline strings are preserved`() = assertFormatted( """ - |val x = ""${'"'} + |val x = $TQ | | | |Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - |""${'"'} - |""".trimMargin()) + |$TQ + |""" + .trimMargin()) @Test fun `Trailing spaces in a comment are not preserved`() { @@ -2419,11 +2632,13 @@ class FormatterTest { @Test fun `Code with tombstones is not supported`() { - val code = """ + val code = + """ |fun good() { | // ${'\u0003'} |} - |""".trimMargin() + |""" + .trimMargin() try { Formatter.format(code) fail() @@ -2444,7 +2659,8 @@ class FormatterTest { |} | |class Foo<T> - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle for loops`() = @@ -2455,7 +2671,8 @@ class FormatterTest { | println(i) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle for loops with long dot chains`() = @@ -2476,7 +2693,8 @@ class FormatterTest { | println(child) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2494,7 +2712,8 @@ class FormatterTest { | number = 2 * number | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2512,7 +2731,8 @@ class FormatterTest { | number = 2 * number | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2527,7 +2747,8 @@ class FormatterTest { | } | .methodCall() |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `keep last expression in qualified indented`() = @@ -2543,7 +2764,8 @@ class FormatterTest { | Foo.doIt() | .doThat()) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2560,7 +2782,8 @@ class FormatterTest { | red.orange.yellow() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2590,7 +2813,8 @@ class FormatterTest { | red.orange.yellow() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2622,7 +2846,8 @@ class FormatterTest { | red.orange.yellow() | // this is a comment |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2646,7 +2871,8 @@ class FormatterTest { | action2() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2663,7 +2889,8 @@ class FormatterTest { | foo1, foo2, foo3) | .doThat() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2680,7 +2907,8 @@ class FormatterTest { | doStuff() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2701,7 +2929,8 @@ class FormatterTest { | a + a | }) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `Qualified type`() = @@ -2712,7 +2941,8 @@ class FormatterTest { | var x: Map.Entry<String, Integer> | var x: List<String>.Iterator |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle destructuring declaration in for loop`() = @@ -2721,7 +2951,8 @@ class FormatterTest { |fun f(a: List<Pair<Int, Int>>) { | for ((x, y: Int) in a) {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle function references`() = @@ -2744,7 +2975,8 @@ class FormatterTest { | invoke(a, b, c):: | functionName |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2762,7 +2994,8 @@ class FormatterTest { |} | |class `more spaces` - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle annotations with arguments`() = @@ -2777,7 +3010,8 @@ class FormatterTest { |class Test { | // |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `no newlines after annotations if entire expr fits in one line`() = @@ -2834,7 +3068,8 @@ class FormatterTest { | println("") | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2845,7 +3080,8 @@ class FormatterTest { |@Suppress("UnsafeCast") |val ClassA.methodA | get() = foo as Bar - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2858,7 +3094,8 @@ class FormatterTest { | @LongLongLongLongLongAnnotation | private val ROW_HEIGHT = 72 |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -2866,7 +3103,8 @@ class FormatterTest { assertFormatted( """ |val callback: (@Anno List<@JvmSuppressWildcards String>) -> Unit = foo - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on type parameters`() = @@ -2875,7 +3113,8 @@ class FormatterTest { |class Foo<@Anno out @Anno T, @Anno in @Anno U> { | inline fun <@Anno reified @Anno X, @Anno reified @Anno Y> bar() {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on type constraints`() = @@ -2884,19 +3123,24 @@ class FormatterTest { |class Foo<T : @Anno Kip, U> where U : @Anno Kip, U : @Anno Qux { | fun <T : @Anno Kip, U> bar() where U : @Anno Kip, U : @Anno Qux {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on type arguments`() = - assertFormatted(""" + assertFormatted( + """ |fun foo(x: Foo<in @Anno Int>) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on destructuring declaration elements`() = - assertFormatted(""" + assertFormatted( + """ |val x = { (@Anno x, @Anno y) -> x } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on exceptions`() = @@ -2909,7 +3153,8 @@ class FormatterTest { | // | } catch (@Suppress("GeneralException") e: Exception) {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `Unary prefix expressions`() = @@ -2940,7 +3185,8 @@ class FormatterTest { | !++a | !--a |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `Unary postfix expressions`() = @@ -2956,7 +3202,8 @@ class FormatterTest { | | a!! !! |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle wildcard generics`() = @@ -2966,39 +3213,59 @@ class FormatterTest { | val l: List<*> | val p: Pair<*, *> |} - |""".trimMargin()) + |""" + .trimMargin()) + + @Test + fun `handle intersection generics`() = + assertFormatted( + """ + |fun f() { + | val l: Decl<A & B & C> + | val p = Ctor<A & B & C, T & Y & Z> + |} + |""" + .trimMargin()) @Test fun `handle covariant and contravariant type arguments`() = - assertFormatted(""" + assertFormatted( + """ |val p: Pair<in T, out S> - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle covariant and contravariant type parameters`() = - assertFormatted(""" + assertFormatted( + """ |class Foo<in T, out S> - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle bounds for type parameters`() = - assertFormatted(""" + assertFormatted( + """ |class Foo<in T : List<*>, out S : Any?> - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on classes`() = assertFormatted( """ |class Foo<T>(n: Int) where T : Bar, T : FooBar {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on functions`() = assertFormatted( """ |fun <T> foo(n: Int) where T : Bar, T : FooBar {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on properties`() = @@ -3008,7 +3275,8 @@ class FormatterTest { | get() { | return 2 * sum() | } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on class with delegate`() = @@ -3016,7 +3284,8 @@ class FormatterTest { """ |class Foo<T>() : Bar by bar |where T : Qux - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `explicit type on property getter`() = @@ -3026,7 +3295,8 @@ class FormatterTest { | val silly: Int | get(): Int = 1 |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle method calls with lambda arg only`() = @@ -3035,7 +3305,8 @@ class FormatterTest { |fun f() { | val a = g { 1 + 1 } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle method calls value args and a lambda arg`() = @@ -3044,7 +3315,8 @@ class FormatterTest { |fun f() { | val a = g(1, 2) { 1 + 1 } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle top level constants`() = @@ -3056,7 +3328,8 @@ class FormatterTest { |const val b = "a" | |val a = 5 - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3066,7 +3339,8 @@ class FormatterTest { |fun f() { | val b = { x: Int, y: Int -> x + y } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `avoid newline before lambda argument if it is named`() = @@ -3078,9 +3352,12 @@ class FormatterTest { | lambdaArgument = { | step1() | step2() - | }) { it.doIt() } + | }) { + | it.doIt() + | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle labeled this pointer`() = @@ -3091,13 +3368,16 @@ class FormatterTest { | g { println(this@Foo) } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle extension and operator functions`() = - assertFormatted(""" + assertFormatted( + """ |operator fun Point.component1() = x - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle extension methods with very long names`() = @@ -3111,7 +3391,8 @@ class FormatterTest { | n: Int, | f: Float |) {} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3120,13 +3401,16 @@ class FormatterTest { """ |val Int.isPrime: Boolean | get() = runMillerRabinPrimality(this) - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `generic extension property`() = - assertFormatted(""" + assertFormatted( + """ |val <T> List<T>.twiceSize = 2 * size() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle file annotations`() = @@ -3141,7 +3425,8 @@ class FormatterTest { |class Foo { | val a = example2("and 1") |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle init block`() = @@ -3152,20 +3437,24 @@ class FormatterTest { | println("Init!") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle interface delegation`() = assertFormatted( """ |class MyList(impl: List<Int>) : Collection<Int> by impl - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle property delegation`() = - assertFormatted(""" + assertFormatted( + """ |val a by lazy { 1 + 1 } - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle property delegation with type and breaks`() = @@ -3186,7 +3475,8 @@ class FormatterTest { | |val importantValue: Int by | doIt(1 + 1) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3205,7 +3495,8 @@ class FormatterTest { | var httpClient: OkHttpClient |} | - """.trimMargin()) + """ + .trimMargin()) @Test fun `handle parameters with annoations with parameters`() = @@ -3217,7 +3508,8 @@ class FormatterTest { | } |} | - """.trimMargin()) + """ + .trimMargin()) @Test fun `handle lambda types`() = @@ -3230,13 +3522,16 @@ class FormatterTest { |val listener3: (Int, Double) -> Int = { a, b -> a } | |val listener4: Int.(Int, Boolean) -> Unit - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle unicode in string literals`() = - assertFormatted(""" + assertFormatted( + """ |val a = "\uD83D\uDC4D" - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle casting`() = @@ -3248,7 +3543,8 @@ class FormatterTest { | doIt(o as Int) | doIt(o as? Int) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle casting with breaks`() = @@ -3294,7 +3590,8 @@ class FormatterTest { |val a = | l.sOrNull() is | SomethingLongEnough - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3305,7 +3602,8 @@ class FormatterTest { |fun doIt(o: Object) { | // |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle try, catch and finally`() = @@ -3320,7 +3618,8 @@ class FormatterTest { | println("finally") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle infix methods`() = @@ -3329,7 +3628,8 @@ class FormatterTest { |fun numbers() { | (0 until 100).size |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle while loops`() = @@ -3340,7 +3640,8 @@ class FormatterTest { | println("Everything is okay") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle do while loops`() = @@ -3353,7 +3654,8 @@ class FormatterTest { | | do while (1 < 2) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle break and continue`() = @@ -3369,7 +3671,8 @@ class FormatterTest { | } | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle all kinds of labels and jumps`() = @@ -3391,7 +3694,8 @@ class FormatterTest { | return@map 2 * it | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `don't crash on top level statements with semicolons`() { @@ -3404,7 +3708,8 @@ class FormatterTest { |foo { 0 }; | |val fill = 0; - |""".trimMargin() + |""" + .trimMargin() val expected = """ |val x = { 0 } @@ -3414,7 +3719,8 @@ class FormatterTest { |foo { 0 } | |val fill = 0 - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -3431,7 +3737,8 @@ class FormatterTest { | | fun isOne(): Boolean = this == ONE |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |enum class SemiColonIsNotRequired { @@ -3445,7 +3752,8 @@ class FormatterTest { | | fun isOne(): Boolean = this == ONE |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -3455,24 +3763,26 @@ class FormatterTest { """ |fun f() { | val x = ";" - | val x = ""${'"'} don't touch ; in raw strings ""${'"'} + | val x = $TQ don't touch ; in raw strings $TQ |} | |// Don't touch ; inside comments. | |/** Don't touch ; inside comments. */ - |""".trimMargin() + |""" + .trimMargin() val expected = """ |fun f() { | val x = ";" - | val x = ""${'"'} don't touch ; in raw strings ""${'"'} + | val x = $TQ don't touch ; in raw strings $TQ |} | |// Don't touch ; inside comments. | |/** Don't touch ; inside comments. */ - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -3491,7 +3801,8 @@ class FormatterTest { | else | ; |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |fun f() { @@ -3503,11 +3814,12 @@ class FormatterTest { | if (true) ; | if (true) | /** a */ - | ; + | ; | | if (true) else ; |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -3539,7 +3851,8 @@ class FormatterTest { | // Literally any callable expression is dangerous | val x = (if (cond) x::foo else x::bar); { dead -> lambda } |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |fun f() { @@ -3579,7 +3892,8 @@ class FormatterTest { | val x = (if (cond) x::foo else x::bar); | { dead -> lambda } |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -3603,9 +3917,11 @@ class FormatterTest { | someLongVariableName.let { | someReallyLongFunctionNameThatMakesThisNotFitInOneLineWithTheAboveVariable(); | } + | if (cond) ; else 6 |} ; | - |""".trimMargin() + |""" + .trimMargin() val expected = """ |package org.examples @@ -3630,24 +3946,29 @@ class FormatterTest { | someLongVariableName.let { | someReallyLongFunctionNameThatMakesThisNotFitInOneLineWithTheAboveVariable() | } + | if (cond) else 6 |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @Test fun `pretty-print after dropping redundant semicolons`() { - val code = """ + val code = + """ |fun f() { | val veryLongName = 5; |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |fun f() { | val veryLongName = 5 |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).withOptions(FormattingOptions(maxWidth = 22)).isEqualTo(expected) } @@ -3658,7 +3979,8 @@ class FormatterTest { |fun f() { | a { println("a") } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle multi statement lambdas`() = @@ -3670,7 +3992,8 @@ class FormatterTest { | println("b") | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle multi line one statement lambda`() = @@ -3682,7 +4005,8 @@ class FormatterTest { | println(foo.bar.boom) | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3695,7 +4019,8 @@ class FormatterTest { | return | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `properly break fully qualified nested user types`() = @@ -3710,7 +4035,8 @@ class FormatterTest { | Int, Nothing>, | Nothing>> = | DUMMY - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3740,7 +4066,8 @@ class FormatterTest { | .sum | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle multi line lambdas with explicit args`() = @@ -3752,7 +4079,8 @@ class FormatterTest { | x + y | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3763,7 +4091,8 @@ class FormatterTest { | g { (a, b): List<Int> -> a } | g { (a, b): List<Int>, (c, d): List<Int> -> a } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle parenthesis in lambda calls for now`() = @@ -3772,7 +4101,8 @@ class FormatterTest { |fun f() { | a() { println("a") } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle chaining of calls with lambdas`() = @@ -3788,7 +4118,8 @@ class FormatterTest { | } | .sum |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle break of lambda args per line with indentation`() = @@ -3812,7 +4143,8 @@ class FormatterTest { | doIt() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3830,7 +4162,8 @@ class FormatterTest { | doIt() | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3845,7 +4178,8 @@ class FormatterTest { | .find { it.contains(someSearchValue) } | ?: someDefaultValue |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3861,7 +4195,8 @@ class FormatterTest { | // this is a comment | .doItTwice() |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -3875,7 +4210,8 @@ class FormatterTest { |inline fun <reified in T> foo2(t: T) { | println(t) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle suspended types`() = @@ -3888,7 +4224,8 @@ class FormatterTest { |inline fun <R> foo(noinline block: suspend () -> R): suspend () -> R | |inline fun <R> bar(noinline block: (suspend () -> R)?): (suspend () -> R)? - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle simple enum classes`() = @@ -3899,7 +4236,8 @@ class FormatterTest { | FALSE, | FILE_NOT_FOUND, |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle enum class with functions`() = @@ -3913,7 +4251,8 @@ class FormatterTest { | return true | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle enum with annotations`() = @@ -3923,7 +4262,8 @@ class FormatterTest { | @True TRUE, | @False @WhatIsTruth FALSE, |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle enum constructor calls`() = @@ -3933,7 +4273,8 @@ class FormatterTest { | TRUE("true"), | FALSE("false", false), |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle enum entries with body`() = @@ -3945,20 +4286,25 @@ class FormatterTest { | }, | FISH(false) {}, |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle empty enum`() = - assertFormatted(""" + assertFormatted( + """ |enum class YTho { |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `expect enum class`() = - assertFormatted(""" + assertFormatted( + """ |expect enum class ExpectedEnum - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `enum without trailing comma`() = @@ -3967,7 +4313,8 @@ class FormatterTest { |enum class Highlander { | ONE |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `enum comma and semicolon`() { @@ -3976,13 +4323,15 @@ class FormatterTest { |enum class Highlander { | ONE,; |} - |""".trimMargin()) + |""" + .trimMargin()) .isEqualTo( """ |enum class Highlander { | ONE, |} - |""".trimMargin()) + |""" + .trimMargin()) } @Test @@ -3996,7 +4345,8 @@ class FormatterTest { | | fun f() {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle varargs and spread operator`() = @@ -4006,7 +4356,8 @@ class FormatterTest { | foo2(*args) | foo3(options = *args) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle typealias`() = @@ -4019,7 +4370,8 @@ class FormatterTest { |typealias PairPair<X, Y> = Pair<Pair<X, Y>, X> | |class Foo - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4029,7 +4381,8 @@ class FormatterTest { |fun x(): dynamic = "x" | |val dyn: dynamic = 1 - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle class expression with generics`() = @@ -4038,7 +4391,8 @@ class FormatterTest { |fun f() { | println(Array<String>::class.java) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `ParseError contains correct line and column numbers`() { @@ -4050,7 +4404,8 @@ class FormatterTest { |} | |fn ( - |""".trimMargin() + |""" + .trimMargin() try { Formatter.format(code) fail() @@ -4069,7 +4424,8 @@ class FormatterTest { |fun good() { | return@ 5 |} - |""".trimMargin() + |""" + .trimMargin() try { Formatter.format(code) fail() @@ -4090,7 +4446,8 @@ class FormatterTest { | return (@Fancy 1) | } |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on function types`() = @@ -4113,7 +4470,8 @@ class FormatterTest { | (@field:[Inject Named("WEB_VIEW")] | (x) -> Unit) |) {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle annotations with use-site targets`() = @@ -4124,7 +4482,8 @@ class FormatterTest { | | @set:Magic(name = "Jane") var field: String |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle annotations mixed with keywords since we cannot reorder them for now`() = @@ -4135,7 +4494,8 @@ class FormatterTest { |public @Magic(1) final class Foo | |@Magic(1) public final class Foo - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle annotations more`() = @@ -4154,7 +4514,8 @@ class FormatterTest { | @Annotation // test a comment after annotations | return 5 |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4191,7 +4552,8 @@ class FormatterTest { | add(20) && | add(30) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4207,7 +4569,8 @@ class FormatterTest { |fun f() { | add(10) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotated class declarations`() = @@ -4221,14 +4584,16 @@ class FormatterTest { |// Foo |@Anno("param") |class F - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle type arguments in annotations`() = assertFormatted( """ |@TypeParceler<UUID, UUIDParceler>() class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle one line KDoc`() = @@ -4236,7 +4601,8 @@ class FormatterTest { """ |/** Hi, I am a one line kdoc */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with Link`() = @@ -4244,7 +4610,8 @@ class FormatterTest { """ |/** This links to [AnotherClass] */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with paragraphs`() = @@ -4256,7 +4623,8 @@ class FormatterTest { | * There's a space line to preserve between them | */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with blocks`() = @@ -4269,7 +4637,8 @@ class FormatterTest { | * @param[param2] this is param2 | */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with code examples`() = @@ -4277,7 +4646,6 @@ class FormatterTest { """ |/** | * This is how you write a simple hello world in Kotlin: - | * | * ``` | * fun main(args: Array<String>) { | * println("Hello World!") @@ -4285,13 +4653,16 @@ class FormatterTest { | * ``` | * | * Amazing ah? + | * | * ``` | * fun `code can be with a blank line above it` () {} | * ``` + | * | * Or after it! | */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with tagged code examples`() = @@ -4305,7 +4676,8 @@ class FormatterTest { | * ``` | */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle stray code markers in lines and produce stable output`() { @@ -4318,7 +4690,8 @@ class FormatterTest { | * ``` | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertFormatted(Formatter.format(code)) } @@ -4333,7 +4706,8 @@ class FormatterTest { | * ``` | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** @@ -4343,7 +4717,8 @@ class FormatterTest { | * ``` | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -4357,7 +4732,8 @@ class FormatterTest { | * foo ``` wow | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertFormatted(Formatter.format(code)) } @@ -4367,7 +4743,8 @@ class FormatterTest { """ |/** Doc line with a reference to [AnotherClass] in the middle of a sentence */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with links one after another`() = @@ -4375,7 +4752,8 @@ class FormatterTest { """ |/** Here are some links [AnotherClass] [AnotherClass2] */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `don't add spaces after links in Kdoc`() = @@ -4383,7 +4761,8 @@ class FormatterTest { """ |/** Here are some links [AnotherClass][AnotherClass2]hello */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `don't remove spaces after links in Kdoc`() = @@ -4391,7 +4770,8 @@ class FormatterTest { """ |/** Please see [onNext] (which has more details) */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `link anchor in KDoc are preserved`() = @@ -4399,7 +4779,8 @@ class FormatterTest { """ |/** [link anchor](the URL for the link anchor goes here) */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `don't add spaces between links in KDoc (because they're actually references)`() = @@ -4410,7 +4791,8 @@ class FormatterTest { | |/** The final produced value may have [size][ByteString.size] < [bufferSize]. */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `collapse spaces after links in KDoc`() { @@ -4418,12 +4800,14 @@ class FormatterTest { """ |/** Here are some links [Class1], [Class2] [Class3]. hello */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** Here are some links [Class1], [Class2] [Class3]. hello */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -4436,21 +4820,25 @@ class FormatterTest { | * [Class2] | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** Here are some links [Class1] [Class2] */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @Test fun `do not crash because of malformed KDocs and produce stable output`() { - val code = """ + val code = + """ |/** Surprise ``` */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertFormatted(Formatter.format(code)) } @@ -4463,7 +4851,8 @@ class FormatterTest { | |/** There are many [FooObject]s. */ |class MyClass {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle KDoc with multiple separated param tags, breaking and merging lines and missing asterisk`() { @@ -4484,7 +4873,8 @@ class FormatterTest { | * @see kotlin.text.isWhitespace | */ |class ThisWasCopiedFromTheTrimMarginMethod {} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** @@ -4497,14 +4887,14 @@ class FormatterTest { | * Doesn't preserve the original line endings. | * | * @param marginPrefix non-blank string, which is used as a margin delimiter. Default is `|` (pipe - | * character). - | * + | * character). | * @sample samples.text.Strings.trimMargin | * @see trimIndent | * @see kotlin.text.isWhitespace | */ |class ThisWasCopiedFromTheTrimMarginMethod {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } @@ -4514,7 +4904,8 @@ class FormatterTest { """ |/** Lorem ipsum dolor sit amet, consectetur */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() val expected = """ |/** @@ -4522,7 +4913,8 @@ class FormatterTest { | * consectetur | */ |class MyClass {} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).withOptions(FormattingOptions(maxWidth = 33)).isEqualTo(expected) } @@ -4537,7 +4929,8 @@ class FormatterTest { | println(child) | } |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code) .withOptions(FormattingOptions(maxWidth = 35, blockIndent = 4, continuationIndent = 4)) .isEqualTo(code) @@ -4550,7 +4943,8 @@ class FormatterTest { |fun doIt() {} | |/* this is the first comment */ - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `preserve LF, CRLF and CR line endings`() { @@ -4579,7 +4973,8 @@ class FormatterTest { | a: Int, | b: Int |) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4603,7 +4998,8 @@ class FormatterTest { | a: Int, | b: Int |) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4630,7 +5026,8 @@ class FormatterTest { | b: Int | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4666,7 +5063,8 @@ class FormatterTest { | b: Int, | c: Int, |) {} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4706,7 +5104,8 @@ class FormatterTest { | 3, | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4718,7 +5117,8 @@ class FormatterTest { | set( | value, | ) {} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4732,7 +5132,8 @@ class FormatterTest { | Int, | ) -> Unit |) {} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4741,9 +5142,12 @@ class FormatterTest { """ |-------------------------- |fun foo() { - | foo({ it },) + | foo( + | { it }, + | ) |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4807,7 +5211,8 @@ class FormatterTest { | // | } |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4847,7 +5252,8 @@ class FormatterTest { | foo() | // | } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -4884,7 +5290,8 @@ class FormatterTest { |val g = 1 | |data class Qux(val foo: String) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) assertThatFormatting( @@ -4893,7 +5300,8 @@ class FormatterTest { |import com.example.bar |const val SOME_CONST = foo.a |val SOME_STR = bar.a - |""".trimMargin()) + |""" + .trimMargin()) .isEqualTo( """ |import com.example.bar @@ -4901,18 +5309,23 @@ class FormatterTest { | |const val SOME_CONST = foo.a |val SOME_STR = bar.a - |""".trimMargin()) + |""" + .trimMargin()) } @Test fun `first line is never empty`() = - assertThatFormatting(""" + assertThatFormatting( + """ | |fun f() {} - |""".trimMargin()) - .isEqualTo(""" + |""" + .trimMargin()) + .isEqualTo( + """ |fun f() {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `at most one newline between any adjacent top-level elements`() = @@ -4940,7 +5353,8 @@ class FormatterTest { | | |val x = Bar() - |""".trimMargin()) + |""" + .trimMargin()) .isEqualTo( """ |import com.Bar @@ -4957,7 +5371,8 @@ class FormatterTest { |val x = Foo() | |val x = Bar() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `at least one newline between any adjacent top-level elements, unless it's a property`() = @@ -4971,7 +5386,8 @@ class FormatterTest { |class C {} |val x = Foo() |val x = Bar() - |""".trimMargin()) + |""" + .trimMargin()) .isEqualTo( """ |import com.Bar @@ -4987,7 +5403,8 @@ class FormatterTest { | |val x = Foo() |val x = Bar() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle array of annotations with field prefix`() { @@ -4998,7 +5415,8 @@ class FormatterTest { | var myVar: String? = null |} | - """.trimMargin() + """ + .trimMargin() assertThatFormatting(code).isEqualTo(code) } @@ -5011,7 +5429,8 @@ class FormatterTest { | var myVar: String? = null |} | - """.trimMargin() + """ + .trimMargin() assertThatFormatting(code).isEqualTo(code) } @@ -5031,7 +5450,8 @@ class FormatterTest { | } | } |} - |""".trimMargin() + |""" + .trimMargin() // Don't throw. Formatter.format(code) @@ -5047,7 +5467,8 @@ class FormatterTest { | val y = 0 | y |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `lambda with optional arrow`() = @@ -5059,7 +5480,8 @@ class FormatterTest { | val y = 0 | y |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `lambda missing optional arrow`() = @@ -5071,7 +5493,8 @@ class FormatterTest { | val y = 0 | y |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `chaining - many dereferences`() = @@ -5086,7 +5509,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5095,7 +5519,8 @@ class FormatterTest { """ |--------------------------------------------------------------------------- |rainbow.red.orange.yellow.green.blue.indigo.violet.cyan.magenta.key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5112,7 +5537,8 @@ class FormatterTest { | .magenta | .key | .build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5121,7 +5547,8 @@ class FormatterTest { """ |--------------------------------------------------------------------------- |rainbow.red.orange.yellow.green.blue.indigo.violet.cyan.magenta.key.build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5139,7 +5566,8 @@ class FormatterTest { | .key | .build() | .shine() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5156,7 +5584,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5174,7 +5603,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5191,7 +5621,8 @@ class FormatterTest { | .magenta | .key | .build { it.appear } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5208,7 +5639,8 @@ class FormatterTest { | .magenta | .key | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5228,7 +5660,8 @@ class FormatterTest { | it | it | } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5245,7 +5678,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5265,7 +5699,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5283,7 +5718,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5303,7 +5739,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5321,7 +5758,8 @@ class FormatterTest { | .key | .z { it } | .shine() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5342,7 +5780,8 @@ class FormatterTest { | it | } | .shine() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5360,7 +5799,8 @@ class FormatterTest { | .key | .shine() | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5378,7 +5818,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5396,7 +5837,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5412,7 +5854,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5429,7 +5872,8 @@ class FormatterTest { | .magenta | .key | .build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5445,7 +5889,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5462,7 +5907,8 @@ class FormatterTest { | .magenta | .key | .build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5478,7 +5924,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5495,7 +5942,8 @@ class FormatterTest { | .magenta | .key | .build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5515,7 +5963,8 @@ class FormatterTest { | .magenta | .key | .shine() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5535,7 +5984,8 @@ class FormatterTest { | .magenta | .key | .shine() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5544,7 +5994,8 @@ class FormatterTest { """ |------------------------- |rainbow.a().b().c() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5556,7 +6007,8 @@ class FormatterTest { | it | it |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5574,7 +6026,8 @@ class FormatterTest { | .cyan | .magenta | .key - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5585,7 +6038,8 @@ class FormatterTest { |z12.shine() | .bright() | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5596,7 +6050,8 @@ class FormatterTest { |getRainbow( | aa, bb, cc) | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5607,7 +6062,8 @@ class FormatterTest { |z { it } | .shine() | .bright() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5618,7 +6074,8 @@ class FormatterTest { |com.sky.Rainbow | .colorFactory | .build() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5630,7 +6087,8 @@ class FormatterTest { | it | it |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5644,7 +6102,8 @@ class FormatterTest { | it | } | .red - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5657,7 +6116,8 @@ class FormatterTest { | it | it | } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5671,7 +6131,8 @@ class FormatterTest { | it | } | .red - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5683,7 +6144,8 @@ class FormatterTest { | it | it |} - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5696,7 +6158,8 @@ class FormatterTest { | it | it | } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5710,7 +6173,8 @@ class FormatterTest { | it | it | } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5722,7 +6186,8 @@ class FormatterTest { | infrared, | ultraviolet, |) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5736,7 +6201,8 @@ class FormatterTest { | ultraviolet, | ) | .red - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5749,7 +6215,8 @@ class FormatterTest { | infrared, | ultraviolet, | ) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5763,7 +6230,8 @@ class FormatterTest { | infrared, | ultraviolet, | ) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5778,7 +6246,8 @@ class FormatterTest { | ultraviolet, | ) | .red - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5792,7 +6261,8 @@ class FormatterTest { | ultraviolet, | ) | .bright() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5806,7 +6276,8 @@ class FormatterTest { | ultraviolet, | ) | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5819,7 +6290,8 @@ class FormatterTest { | ultraviolet, | ) | .bright() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5832,7 +6304,8 @@ class FormatterTest { | ultraviolet, | ) | .bright() - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5844,7 +6317,8 @@ class FormatterTest { | infrared, | ultraviolet, |) - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5857,7 +6331,8 @@ class FormatterTest { | ultraviolet, | ) | .z { it } - |""".trimMargin(), + |""" + .trimMargin(), deduceMaxWidth = true) @Test @@ -5880,7 +6355,8 @@ class FormatterTest { | | @Anno1 /* comment */ @Anno2 f(1) as Int |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations for expressions 2`() { @@ -5891,7 +6367,8 @@ class FormatterTest { | @Suppress("UNCHECKED_CAST") | f(1 + f(1) as Int) |} - |""".trimMargin() + |""" + .trimMargin() val expected = """ @@ -5899,8 +6376,75 @@ class FormatterTest { | @Suppress("UNCHECKED_CAST") f(1 + f(1) as Int) | @Suppress("UNCHECKED_CAST") f(1 + f(1) as Int) |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).isEqualTo(expected) } + + @Test + fun `function call following long multiline string`() = + assertFormatted( + """ + |-------------------------------- + |fun f() { + | val str1 = + | $TQ + | Some very long string that might mess things up + | $TQ + | .trimIndent() + | + | val str2 = + | $TQ + | Some very long string that might mess things up + | $TQ + | .trimIndent(someArg) + |} + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `array-literal in annotation`() = + assertFormatted( + """ + |-------------------------------- + |@Anno( + | array = + | [ + | someItem, + | andAnother, + | noTrailingComma]) + |class Host + | + |@Anno( + | array = + | [ + | someItem, + | andAnother, + | withTrailingComma, + | ]) + |class Host + | + |@Anno( + | array = + | [ + | // Comment + | someItem, + | // Comment + | andAnother, + | // Comment + | withTrailingComment + | // Comment + | // Comment + | ]) + |class Host + |""" + .trimMargin(), + deduceMaxWidth = true) + + companion object { + /** Triple quotes, useful to use within triple-quoted strings. */ + private const val TQ = "\"\"\"" + } } diff --git a/core/src/test/java/com/facebook/ktfmt/format/GoogleStyleFormatterKtTest.kt b/core/src/test/java/com/facebook/ktfmt/format/GoogleStyleFormatterKtTest.kt index 61b6b47..b09fb48 100644 --- a/core/src/test/java/com/facebook/ktfmt/format/GoogleStyleFormatterKtTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/format/GoogleStyleFormatterKtTest.kt @@ -54,7 +54,8 @@ class GoogleStyleFormatterKtTest { | | ImmutableList.newBuilder().add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).add(1).build() | } - |""".trimMargin() + |""" + .trimMargin() val expected = """ @@ -96,7 +97,8 @@ class GoogleStyleFormatterKtTest { | .add(1) | .build() |} - |""".trimMargin() + |""" + .trimMargin() assertThatFormatting(code).withOptions(Formatter.GOOGLE_FORMAT).isEqualTo(expected) // Don't add more tests here @@ -140,7 +142,8 @@ class GoogleStyleFormatterKtTest { |class C(a: Int, var b: Int, val c: Int) { | // |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -188,7 +191,8 @@ class GoogleStyleFormatterKtTest { |fun c12(a: Int, var b: Int, val c: Int) { | // |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -212,7 +216,8 @@ class GoogleStyleFormatterKtTest { | // | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -233,28 +238,159 @@ class GoogleStyleFormatterKtTest { | } | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @Test - fun `don't one-line lambdas following parameter breaks`() = + fun `no forward propagation of breaks in call expressions (at trailing lambda)`() = + assertFormatted( + """ + |-------------------------- + |fun test() { + | foo_bar_baz__zip<A>(b) { + | c + | } + | foo.bar(baz).zip<A>(b) { + | c + | } + |} + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `forward propagation of breaks in call expressions (at value args)`() = + assertFormatted( + """ + |---------------------- + |fun test() { + | foo_bar_baz__zip<A>( + | b + | ) { + | c + | } + |} + | + |fun test() { + | foo.bar(baz).zip<A>( + | b + | ) { + | c + | } + |} + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `forward propagation of breaks in call expressions (at type args)`() = + assertFormatted( + """ + |------------------- + |fun test() { + | foo_bar_baz__zip< + | A + | >( + | b + | ) { + | c + | } + | foo.bar(baz).zip< + | A + | >( + | b + | ) { + | c + | } + |} + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `expected indent in methods following single-line strings`() = + assertFormatted( + """ + |------------------------- + |"Hello %s".format( + | someLongExpression + |) + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `forced break between multi-line strings and their selectors`() = + assertFormatted( + """ + |------------------------- + |val STRING = + | $TQ + | |foo + | |$TQ + | .wouldFit() + | + |val STRING = + | $TQ + | |foo + | |----------------------------------$TQ + | .wouldntFit() + | + |val STRING = + | $TQ + | |foo + | |$TQ + | .firstLink() + | .secondLink() + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `properly break fully qualified nested user types`() = + assertFormatted( + """ + |------------------------------------------------------- + |val complicated: + | com.example.interesting.SomeType< + | com.example.interesting.SomeType<Int, Nothing>, + | com.example.interesting.SomeType< + | com.example.interesting.SomeType<Int, Nothing>, + | Nothing + | > + | > = + | DUMMY + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `don't one-line lambdas following argument breaks`() = assertFormatted( """ |------------------------------------------------------------------------ |class Foo : Bar() { | fun doIt() { - | // don't break in lambda, no parameter breaks found + | // don't break in lambda, no argument breaks found | fruit.forEach { eat(it) } | - | // don't break in lambda, because we only detect parameter breaks - | // with trailing commas + | // break in lambda, without comma | fruit.forEach( | someVeryLongParameterNameThatWillCauseABreak, | evenWithoutATrailingCommaOnTheParameterListSoLetsSeeIt - | ) { eat(it) } + | ) { + | eat(it) + | } | - | // break in the lambda + | // break in the lambda, with comma | fruit.forEach( | fromTheVine = true, | ) { @@ -291,7 +427,8 @@ class GoogleStyleFormatterKtTest { | } | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -308,7 +445,8 @@ class GoogleStyleFormatterKtTest { | 123456789012345678901234567890 | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT) @Test @@ -338,7 +476,8 @@ class GoogleStyleFormatterKtTest { | } | .build() |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, ) @@ -353,7 +492,8 @@ class GoogleStyleFormatterKtTest { | } | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, ) @@ -368,9 +508,12 @@ class GoogleStyleFormatterKtTest { | step1() | step2() | } - | ) { it.doIt() } + | ) { + | it.doIt() + | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, ) @@ -385,7 +528,8 @@ class GoogleStyleFormatterKtTest { | } | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, ) @@ -402,7 +546,8 @@ class GoogleStyleFormatterKtTest { | c = 3456789012345678901234567890 | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT) @Test @@ -430,7 +575,8 @@ class GoogleStyleFormatterKtTest { | ) | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -450,7 +596,8 @@ class GoogleStyleFormatterKtTest { | .doThat() | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -468,7 +615,8 @@ class GoogleStyleFormatterKtTest { | ) | return if (b) 1 else 2 |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT) @Test @@ -484,7 +632,8 @@ class GoogleStyleFormatterKtTest { | }, | duration = duration | ) - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT) @Test @@ -507,7 +656,8 @@ class GoogleStyleFormatterKtTest { | ) + | value9 |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -556,7 +706,8 @@ class GoogleStyleFormatterKtTest { | b is String | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -587,7 +738,8 @@ class GoogleStyleFormatterKtTest { | State(0) | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -616,7 +768,8 @@ class GoogleStyleFormatterKtTest { | ) | .doThat() |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -641,7 +794,8 @@ class GoogleStyleFormatterKtTest { | offspring | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -657,7 +811,8 @@ class GoogleStyleFormatterKtTest { | Foo.createSpeciallyDesignedParameter(), | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -700,7 +855,8 @@ class GoogleStyleFormatterKtTest { | 3, | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -755,7 +911,8 @@ class GoogleStyleFormatterKtTest { | .methodName4() | .abcdefghijkl() |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -778,7 +935,8 @@ class GoogleStyleFormatterKtTest { | } | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT) @Test @@ -796,7 +954,8 @@ class GoogleStyleFormatterKtTest { | println("b") | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -815,7 +974,59 @@ class GoogleStyleFormatterKtTest { | doItOnce() | doItTwice() |} - |""".trimMargin()) + |""" + .trimMargin()) + + @Test + fun `comma separated lists, no automatic trailing break after lambda params`() = + assertFormatted( + """ + |---------------------------- + |fun foo() { + | someExpr.let { x -> x } + | someExpr.let { x, y -> x } + | + | someExpr.let { paramFits + | -> + | butNotArrow + | } + | someExpr.let { params, fit + | -> + | butNotArrow + | } + | + | someExpr.let { + | parameterToLong -> + | fits + | } + | someExpr.let { + | tooLong, + | together -> + | fits + | } + |} + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `comma separated lists, no automatic trailing break after supertype list`() = + assertFormatted( + """ + |---------------------------- + |class Foo() : + | ThisList, + | WillBe, + | TooLong(thats = ok) { + | fun someMethod() { + | val forceBodyBreak = 0 + | } + |} + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) @Test fun `if expression with multiline condition`() = @@ -841,7 +1052,8 @@ class GoogleStyleFormatterKtTest { | bar() | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -857,7 +1069,8 @@ class GoogleStyleFormatterKtTest { | bar() | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -887,7 +1100,8 @@ class GoogleStyleFormatterKtTest { | 2 -> print(2) | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -904,7 +1118,8 @@ class GoogleStyleFormatterKtTest { | 2 -> print(2) | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -932,7 +1147,8 @@ class GoogleStyleFormatterKtTest { | bar() | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -948,7 +1164,8 @@ class GoogleStyleFormatterKtTest { | bar() | } |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -970,7 +1187,8 @@ class GoogleStyleFormatterKtTest { | boo | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -988,7 +1206,8 @@ class GoogleStyleFormatterKtTest { | param2 | ) |} - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -1002,7 +1221,8 @@ class GoogleStyleFormatterKtTest { | .doOp(1) | .doOp(2) |) - |""".trimMargin(), + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) @@ -1018,7 +1238,80 @@ class GoogleStyleFormatterKtTest { | c: String, | d: String | ) -> Unit - |""".trimMargin(), + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + @Test + fun `function call following long multiline string`() = + assertFormatted( + """ + |-------------------------------- + |fun f() { + | val str1 = + | $TQ + | Some very long string that might mess things up + | $TQ + | .trimIndent() + | + | val str2 = + | $TQ + | Some very long string that might mess things up + | $TQ + | .trimIndent(someArg) + |} + |""" + .trimMargin(), formattingOptions = Formatter.GOOGLE_FORMAT, deduceMaxWidth = true) + + @Test + fun `array-literal in annotation`() = + assertFormatted( + """ + |-------------------------------- + |@Anno( + | array = + | [ + | someItem, + | andAnother, + | noTrailingComma + | ] + |) + |class Host + | + |@Anno( + | array = + | [ + | someItem, + | andAnother, + | withTrailingComma, + | ] + |) + |class Host + | + |@Anno( + | array = + | [ + | // Comment + | someItem, + | // Comment + | andAnother, + | // Comment + | withTrailingComment + | // Comment + | // Comment + | ] + |) + |class Host + |""" + .trimMargin(), + formattingOptions = Formatter.GOOGLE_FORMAT, + deduceMaxWidth = true) + + companion object { + /** Triple quotes, useful to use within triple-quoted strings. */ + private const val TQ = "\"\"\"" + } } diff --git a/core/src/test/java/com/facebook/ktfmt/kdoc/DokkaVerifier.kt b/core/src/test/java/com/facebook/ktfmt/kdoc/DokkaVerifier.kt new file mode 100644 index 0000000..76fe54d --- /dev/null +++ b/core/src/test/java/com/facebook/ktfmt/kdoc/DokkaVerifier.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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. + */ + +@file:Suppress("PropertyName", "PrivatePropertyName") + +package com.facebook.ktfmt.kdoc + +import com.google.common.truth.Truth.assertThat +import java.io.BufferedReader +import java.io.File + +/** + * Verifies that two KDoc comment strings render to the same HTML documentation using Dokka. This is + * used by the test infrastructure to make sure that the transformations we're allowing are not + * changing the appearance of the documentation. + * + * Unfortunately, just diffing HTML strings isn't always enough, because dokka will preserve some + * text formatting which is immaterial to the HTML appearance. Therefore, if you've also installed + * Pandoc, it will use that to generate a text rendering of the HTML which is then used for diffing + * instead. (Even this isn't fullproof because pandoc also preserves some details that should not + * matter). Text rendering does drop a lot of markup (such as bold and italics) so it would be + * better to compare in some other format, such as PDF, but unfortunately, the PDF rendering doesn't + * appear to be stable; rendering the same document twice yields a binary diff. + * + * Dokka no longer provides a fat/shadow jar; instead you have to download a bunch of different + * dependencies. Therefore, for convenience this is set up to point to an AndroidX checkout, which + * has all the prebuilts. Point the below to AndroidX and the rest should work. + */ +class DokkaVerifier(private val tempFolder: File) { + // Configuration parameters + // Checkout of https://github.com/androidx/androidx + private val ANDROIDX_HOME: String? = null + + // Optional install of pandoc, e.g. "/opt/homebrew/bin/pandoc" + private val PANDOC: String? = null + + // JDK install + private val JAVA_HOME: String? = System.getenv("JAVA_HOME") ?: System.getProperty("java.home") + + fun verify(before: String, after: String) { + JAVA_HOME ?: return + ANDROIDX_HOME ?: return + + val androidx = File(ANDROIDX_HOME) + if (!androidx.isDirectory) { + return + } + + val prebuilts = File(androidx, "prebuilts") + if (!prebuilts.isDirectory) { + println("AndroidX prebuilts not found; not verifying with Dokka") + } + val cli = find(prebuilts, "org.jetbrains.dokka", "dokka-cli") + val analysis = find(prebuilts, "org.jetbrains.dokka", "dokka-analysis") + val base = find(prebuilts, "org.jetbrains.dokka", "dokka-base") + val compiler = find(prebuilts, "org.jetbrains.dokka", "kotlin-analysis-compiler") + val intellij = find(prebuilts, "org.jetbrains.dokka", "kotlin-analysis-intellij") + val coroutines = find(prebuilts, "org.jetbrains.kotlinx", "kotlinx-coroutines-core") + val html = find(prebuilts, "org.jetbrains.kotlinx", "kotlinx-html-jvm") + val freemarker = find(prebuilts, "org.freemarker", "freemarker") + + val src = File(tempFolder, "src") + val out = File(tempFolder, "dokka") + src.mkdirs() + out.mkdirs() + + val beforeFile = File(src, "before.kt") + beforeFile.writeText("${before.split("\n").joinToString("\n") { it.trim() }}\nclass Before\n") + + val afterFile = File(src, "after.kt") + afterFile.writeText("${after.split("\n").joinToString("\n") { it.trim() }}\nclass After\n") + + val args = mutableListOf<String>() + args.add(File(JAVA_HOME, "bin/java").path) + args.add("-jar") + args.add(cli.path) + args.add("-pluginsClasspath") + val pathSeparator = + ";" // instead of File.pathSeparator as would have been reasonable (e.g. : on Unix) + val path = + listOf(analysis, base, compiler, intellij, coroutines, html, freemarker).joinToString( + pathSeparator) { + it.path + } + args.add(path) + args.add("-sourceSet") + args.add("-src $src") // (nested parameter within -sourceSet) + args.add("-outputDir") + args.add(out.path) + executeProcess(args) + + fun getHtml(file: File): String { + val rendered = file.readText() + val begin = rendered.indexOf("<div class=\"copy-popup-wrapper popup-to-left\">") + val end = rendered.indexOf("<div class=\"tabbedcontent\">", begin) + return rendered.substring(begin, end).replace(Regex(" +"), " ").replace(">", ">\n") + } + + fun getText(file: File): String? { + return if (PANDOC != null) { + val pandocFile = File(PANDOC) + if (!pandocFile.isFile) { + error("Cannot execute $pandocFile") + } + val outFile = File(out, "text.text") + executeProcess(listOf(PANDOC, file.path, "-o", outFile.path)) + val rendered = outFile.readText() + + val begin = rendered.indexOf("[]{.copy-popup-icon}Content copied to clipboard") + val end = rendered.indexOf("::: tabbedcontent", begin) + rendered.substring(begin, end).replace(Regex(" +"), " ").replace(">", ">\n") + } else { + null + } + } + + val indexBefore = File("$out/root/[root]/-before/index.html") + val beforeContents = getHtml(indexBefore) + val indexAfter = File("$out/root/[root]/-after/index.html") + val afterContents = getHtml(indexAfter) + if (beforeContents != afterContents) { + val beforeText = getText(indexBefore) + val afterText = getText(indexAfter) + if (beforeText != null && afterText != null) { + assertThat(beforeText).isEqualTo(afterText) + return + } + + assertThat(beforeContents).isEqualTo(afterContents) + } + } + + private fun find(prebuilts: File, group: String, artifact: String): File { + val versionDir = File(prebuilts, "androidx/external/${group.replace('.','/')}/$artifact") + val versions = + versionDir.listFiles().filter { it.name.first().isDigit() }.sortedByDescending { it.name } + for (version in versions.map { it.name }) { + val jar = File(versionDir, "$version/$artifact-$version.jar") + if (jar.isFile) { + return jar + } + } + error("Could not find a valid jar file for $group:$artifact") + } + + private fun executeProcess(args: List<String>) { + var input: BufferedReader? = null + var error: BufferedReader? = null + try { + val process = Runtime.getRuntime().exec(args.toTypedArray()) + input = process.inputStream.bufferedReader() + error = process.errorStream.bufferedReader() + val exitVal = process.waitFor() + if (exitVal != 0) { + val sb = StringBuilder() + sb.append("Failed to execute process\n") + sb.append("Command args:\n") + for (arg in args) { + sb.append(" ").append(arg).append("\n") + } + sb.append("Standard output:\n") + var line: String? + while (input.readLine().also { line = it } != null) { + sb.append(line).append("\n") + } + sb.append("Error output:\n") + while (error.readLine().also { line = it } != null) { + sb.append(line).append("\n") + } + error(sb.toString()) + } + } catch (t: Throwable) { + val sb = StringBuilder() + for (arg in args) { + sb.append(" ").append(arg).append("\n") + } + t.printStackTrace() + error("Could not run process:\n$sb") + } finally { + input?.close() + error?.close() + } + } +} diff --git a/core/src/test/java/com/facebook/ktfmt/kdoc/KDocFormatterTest.kt b/core/src/test/java/com/facebook/ktfmt/kdoc/KDocFormatterTest.kt index 4efdf42..bb1b156 100644 --- a/core/src/test/java/com/facebook/ktfmt/kdoc/KDocFormatterTest.kt +++ b/core/src/test/java/com/facebook/ktfmt/kdoc/KDocFormatterTest.kt @@ -1,5 +1,21 @@ /* - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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. + */ + +/* + * Copyright (c) Tor Norbye. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,28 +32,4686 @@ package com.facebook.ktfmt.kdoc -import com.facebook.ktfmt.kdoc.KDocFormatter.tokenizeKdocText import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlin.io.path.createTempDirectory +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class KDocFormatterTest { + private val tempDir = createTempDirectory().toFile() + + private fun checkFormatter( + task: FormattingTask, + expected: String, + verify: Boolean = true, + verifyDokka: Boolean = true, + ) { + val reformatted = reformatComment(task) + + val indent = task.initialIndent + val options = task.options + val source = task.comment + + // Because .trimIndent() will remove it: + val indentedExpected = expected.split("\n").joinToString("\n") { indent + it } + + assertThat(reformatted).isEqualTo(indentedExpected) + + if (verifyDokka && !options.addPunctuation) { + DokkaVerifier(tempDir).verify(source, reformatted) + } + + // Make sure that formatting is stable -- format again and make sure it's the same + if (verify) { + val again = + FormattingTask( + options, + reformatted.trim(), + task.initialIndent, + task.secondaryIndent, + task.orderedParameterNames) + val formattedAgain = reformatComment(again) + if (reformatted != formattedAgain) { + assertWithMessage("Formatting is unstable: if formatted a second time, it changes") + .that("$indent// FORMATTED TWICE (implies unstable formatting)\n\n$formattedAgain") + .isEqualTo("$indent// FORMATTED ONCE\n\n$reformatted") + } + } + } + + private fun checkFormatter( + source: String, + options: KDocFormattingOptions, + expected: String, + indent: String = " ", + verify: Boolean = true, + verifyDokka: Boolean = true + ) { + val task = FormattingTask(options, source.trim(), indent) + checkFormatter(task, expected, verify, verifyDokka) + } + + private fun reformatComment(task: FormattingTask): String { + val formatter = KDocFormatter(task.options) + val formatted = formatter.reformatComment(task) + return task.initialIndent + formatted + } + + @Test + fun test1() { + checkFormatter( + """ + /** + * Returns whether lint should check all warnings, + * including those off by default, or null if + *not configured in this configuration. This is a really really really long sentence which needs to be broken up. + * And ThisIsALongSentenceWhichCannotBeBrokenUpAndMustBeIncludedAsAWholeWithoutNewlinesInTheMiddle. + * + * This is a separate section + * which should be flowed together with the first one. + * *bold* should not be removed even at beginning. + */ + """ + .trimIndent(), + KDocFormattingOptions(72), + """ + /** + * Returns whether lint should check all warnings, including + * those off by default, or null if not configured in + * this configuration. This is a really really really + * long sentence which needs to be broken up. And + * ThisIsALongSentenceWhichCannotBeBrokenUpAndMustBeIncludedAsAWholeWithoutNewlinesInTheMiddle. + * + * This is a separate section which should be flowed together with + * the first one. *bold* should not be removed even at beginning. + */ + """ + .trimIndent()) + } + + @Test + fun testWithOffset() { + val source = + """ + /** Returns whether lint should check all warnings, + * including those off by default */ + """ + .trimIndent() + val reformatted = + """ + /** + * Returns whether lint should check all warnings, including those + * off by default + */ + """ + .trimIndent() + checkFormatter(source, KDocFormattingOptions(72), reformatted, indent = " ") + val initialOffset = source.indexOf("default") + val newOffset = findSamePosition(source, initialOffset, reformatted) + assertThat(newOffset).isNotEqualTo(initialOffset) + assertThat(reformatted.substring(newOffset, newOffset + "default".length)).isEqualTo("default") + } + + @Test + fun testWordBreaking() { + // Without special handling, the "-" in the below would be placed at the + // beginning of line 2, which then implies a list item. + val source = + """ + /** Returns whether lint should check all warnings, + * including aaaaaa - off by default */ + """ + .trimIndent() + val reformatted = + """ + /** + * Returns whether lint should check all warnings, including + * aaaaaa - off by default + */ + """ + .trimIndent() + checkFormatter(source, KDocFormattingOptions(72), reformatted, indent = " ") + val initialOffset = source.indexOf("default") + val newOffset = findSamePosition(source, initialOffset, reformatted) + assertThat(newOffset).isNotEqualTo(initialOffset) + assertThat(reformatted.substring(newOffset, newOffset + "default".length)).isEqualTo("default") + } + + @Test + fun testHeader() { + val source = + """ + /** + * Information about a request to run lint. + * + * **NOTE: This is not a public or final API; if you rely on this be prepared + * to adjust your code for the next tools release.** + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Information about a request to run lint. + * + * **NOTE: This is not a public or final API; if you rely on this be + * prepared to adjust your code for the next tools release.** + */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Information about a request to run + * lint. + * + * **NOTE: This is not a public or final + * API; if you rely on this be prepared + * to adjust your code for the next + * tools release.** + */ + """ + .trimIndent(), + indent = "") + + checkFormatter( + source, + KDocFormattingOptions(100, 100), + """ + /** + * Information about a request to run lint. + * + * **NOTE: This is not a public or final API; if you rely on this be prepared to adjust your code + * for the next tools release.** + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testSingle() { + val source = + """ + /** + * The lint client requesting the lint check + * + * @return the client, never null + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * The lint client requesting the lint check + * + * @return the client, never null + */ + """ + .trimIndent()) + } + + @Test + fun testEmpty() { + val source = + """ + /** */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(72).apply { collapseSingleLine = false }, + """ + /** + */ + """ + .trimIndent()) + } + + @Test + fun testJavadocParams() { + val source = + """ + /** + * Sets the scope to use; lint checks which require a wider scope set + * will be ignored + * + * @param scope the scope + * + * @return this, for constructor chaining + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Sets the scope to use; lint checks which require a wider scope + * set will be ignored + * + * @param scope the scope + * @return this, for constructor chaining + */ + """ + .trimIndent()) + } + + @Test + fun testBracketParam() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/72 + val source = + """ + /** + * Summary + * @param [ param1 ] some value + * @param[param2] another value + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Summary + * + * @param param1 some value + * @param param2 another value + */ + """ + .trimIndent()) + } + + @Test + fun testMultiLineLink() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/70 + val source = + """ + /** + * Single line is converted {@link foo} + * + * Multi line is converted {@link + * foo} + * + * Single line with hash is converted {@link #foo} + * + * Multi line with has is converted {@link + * #foo} + * + * Don't interpret {@code + * # This is not a header + * * this is + * * not a nested list + * } + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Single line is converted [foo] + * + * Multi line is converted [foo] + * + * Single line with hash is converted [foo] + * + * Multi line with has is converted [foo] + * + * Don't interpret {@code # This is not a header * this is * not a + * nested list } + */ + """ + .trimIndent(), + // {@link} text is not rendered by dokka when it cannot resolve the symbols + verifyDokka = false) + } + + @Test + fun testPreformattedWithinCode() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/77 + val source = + """ + /** + * Some summary. + * {@code + * + * foo < bar?} + * Done. + * + * + * {@code + * ``` + * Some code. + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Some summary. {@code + * + * foo < bar?} Done. + * + * {@code + * + * ``` + * Some code. + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testPreStability() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/78 + val source = + """ + /** + * Some summary + * + * <pre> + * line one + * ``` + * line two + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Some summary + * <pre> + * line one + * ``` + * line two + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testPreStability2() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/78 + // (second scenario + val source = + """ + /** + * Some summary + * + * <pre> + * ``` + * code + * ``` + * </pre> + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Some summary + * <pre> + * ``` + * code + * ``` + * </pre> + */ + """ + .trimIndent()) + } + + @Test + fun testConvertParamReference() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/79 + val source = + """ + /** + * Some summary. + * + * Another summary about {@param someParam}. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Some summary. + * + * Another summary about [someParam]. + */ + """ + .trimIndent(), + // {@param reference} text is not rendered by dokka when it cannot resolve the symbols + verifyDokka = false) + } + + @Test + fun testLineWidth1() { + // Perform in KDocFileFormatter test too to make sure we properly account + // for indent! + val source = + """ + /** + * 89 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 + * + * 10 20 30 40 50 60 70 80 + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * 89 123456789 123456789 123456789 123456789 123456789 123456789 + * 123456789 123456789 + * + * 10 20 30 40 50 60 70 80 + */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * 89 123456789 123456789 123456789 + * 123456789 123456789 123456789 + * 123456789 123456789 + * + * 10 20 30 40 50 60 70 80 + */ + """ + .trimIndent()) + } + + @Test + fun testBlockTagsNoSeparators() { + checkFormatter( + """ + /** + * Marks the given warning as "ignored". + * + * @param context The scanning context + * @param issue the issue to be ignored + * @param location The location to ignore the warning at, if any + * @param message The message for the warning + */ + """ + .trimIndent(), + KDocFormattingOptions(72), + """ + /** + * Marks the given warning as "ignored". + * + * @param context The scanning context + * @param issue the issue to be ignored + * @param location The location to ignore the warning at, if any + * @param message The message for the warning + */ + """ + .trimIndent()) + } + + @Test + fun testBlockTagsHangingIndents() { + val options = KDocFormattingOptions(40) + options.hangingIndent = 6 + checkFormatter( + """ + /** + * Creates a list of class entries from the given class path and specific set of files within + * it. + * + * @param client the client to report errors to and to use to read files + * @param classFiles the specific set of class files to look for + * @param classFolders the list of class folders to look in (to determine the package root) + * @param sort if true, sort the results + * @return the list of class entries, never null. + */ + """ + .trimIndent(), + options, + """ + /** + * Creates a list of class entries + * from the given class path and + * specific set of files within it. + * + * @param client the client to + * report errors to and to use + * to read files + * @param classFiles the specific + * set of class files to look + * for + * @param classFolders the list of + * class folders to look in + * (to determine the package + * root) + * @param sort if true, sort the + * results + * @return the list of class + * entries, never null. + */ + """ + .trimIndent()) + } + + @Test + fun testGreedyBlockIndent() { + val options = KDocFormattingOptions(100, 72) + options.hangingIndent = 6 + checkFormatter( + """ + /** + * Returns the project resources, if available + * + * @param includeModuleDependencies if true, include merged view of + * all module dependencies + * @param includeLibraries if true, include merged view of all + * library dependencies (this also requires all module dependencies) + * @return the project resources, or null if not available + */ + """ + .trimIndent(), + options, + """ + /** + * Returns the project resources, if available + * + * @param includeModuleDependencies if true, include merged view of all + * module dependencies + * @param includeLibraries if true, include merged view of all library + * dependencies (this also requires all module dependencies) + * @return the project resources, or null if not available + */ + """ + .trimIndent()) + } + + @Test + fun testBlockTagsHangingIndents2() { + checkFormatter( + """ + /** + * @param client the client to + * report errors to and to use to + * read files + */ + """ + .trimIndent(), + KDocFormattingOptions(40), + """ + /** + * @param client the client to + * report errors to and to use to + * read files + */ + """ + .trimIndent()) + } + + @Test + fun testSingleLine() { + // Also tests punctuation feature. + val source = + """ + /** + * This could all fit on one line + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** This could all fit on one line */ + """ + .trimIndent()) + val options = KDocFormattingOptions(72) + options.collapseSingleLine = false + options.addPunctuation = true + checkFormatter( + source, + options, + """ + /** + * This could all fit on one line. + */ + """ + .trimIndent()) + } + + @Test + fun testPunctuationWithLabelLink() { + val source = + """ + /** Default implementation of [MyInterface] */ + """ + .trimIndent() + + val options = KDocFormattingOptions(72) + options.addPunctuation = true + checkFormatter( + source, + options, + """ + /** Default implementation of [MyInterface]. */ + """ + .trimIndent()) + } + @Test - fun testTokenizeKdocText() { - assertThat(tokenizeKdocText(" one two three ").asIterable()) - .containsExactly(" ", "one", " ", "two", " ", "three", " ") - .inOrder() - assertThat(tokenizeKdocText("one two three ").asIterable()) - .containsExactly("one", " ", "two", " ", "three", " ") - .inOrder() - assertThat(tokenizeKdocText("one two three").asIterable()) - .containsExactly("one", " ", "two", " ", "three") - .inOrder() - assertThat(tokenizeKdocText("onetwothree").asIterable()) - .containsExactly("onetwothree") - .inOrder() - assertThat(tokenizeKdocText("").asIterable()).isEmpty() + fun testWrapingOfLinkText() { + val source = + """ + /** + * Sometimes the text of a link can have spaces, like [this link's text](https://example.com). + * The following text should wrap like usual. + */ + """ + .trimIndent() + + val options = KDocFormattingOptions(72) + checkFormatter( + source, + options, + """ + /** + * Sometimes the text of a link can have spaces, like + * [this link's text](https://example.com). The following text + * should wrap like usual. + */ + """ + .trimIndent()) + } + + @Test + fun testPreformattedTextIndented() { + val source = + """ + /** + * Parser for the list of forward socket connection returned by the + * `host:forward-list` command. + * + * Input example + * + * ``` + * + * HT75B1A00212 tcp:51222 tcp:5000 HT75B1A00212 tcp:51227 tcp:5001 + * HT75B1A00212 tcp:51232 tcp:5002 HT75B1A00212 tcp:51239 tcp:5003 + * HT75B1A00212 tcp:51244 tcp:5004 + * + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, KDocFormattingOptions(72, 72).apply { convertMarkup = true }, source, indent = "") + } + + @Test + fun testPreformattedText() { + val source = + """ + /** + * Code sample: + * + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * + * This is not preformatted and can be combined into multiple sentences again. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Code sample: + * + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * + * This is not preformatted and + * can be combined into multiple + * sentences again. + */ + """ + .trimIndent()) + } + + @Test + fun testPreformattedText2() { + val source = + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * ``` + * + * This is not preformatted and can be combined into multiple sentences again. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * ``` + * + * This is not preformatted and + * can be combined into multiple + * sentences again. + */ + """ + .trimIndent()) + } + + @Test + fun testPreformattedText3() { + val source = + """ + /** + * Code sample: + * <PRE> + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * </pre> + * This is not preformatted and can be combined into multiple sentences again. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Code sample: + * ``` + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * ``` + * + * This is not preformatted and + * can be combined into multiple + * sentences again. + */ + """ + .trimIndent(), + // <pre> and ``` are rendered differently; this is an intentional diff + verifyDokka = false) + } + + @Test + fun testPreformattedTextWithBlankLines() { + val source = + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * + * println(s); + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * + * println(s); + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testPreformattedTextWithBlankLinesAndTrailingSpaces() { + val source = + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * + * println(s); + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Code sample: + * ```kotlin + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * + * println(s); + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testPreformattedTextSeparation() { + val source = + """ + /** + * For example, + * + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * And here's another example: + * This is not preformatted text. + * + * And a third example, + * + * ``` + * Preformatted. + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * For example, + * + * val s = "hello, and this is code so should not be line broken at all, it should stay on one line"; + * println(s); + * + * And here's another example: This + * is not preformatted text. + * + * And a third example, + * ``` + * Preformatted. + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testSeparateParagraphMarkers1() { + // If the markup still contains HTML paragraph separators, separate + // paragraphs + val source = + """ + /** + * Here's paragraph 1. + * + * And here's paragraph 2. + * <p>And here's paragraph 3. + * <P/>And here's paragraph 4. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40).apply { convertMarkup = true }, + """ + /** + * Here's paragraph 1. + * + * And here's paragraph 2. + * + * And here's paragraph 3. + * + * And here's paragraph 4. + */ + """ + .trimIndent()) + } + + @Test + fun testSeparateParagraphMarkers2() { + // From ktfmt Tokenizer.kt + val source = + """ + /** + * Tokenizer traverses a Kotlin parse tree (which blessedly contains whitespaces and comments, + * unlike Javac) and constructs a list of 'Tok's. + * + * <p>The google-java-format infra expects newline Toks to be separate from maximal-whitespace Toks, + * but Kotlin emits them together. So, we split them using Java's \R regex matcher. We don't use + * 'split' et al. because we want Toks for the newlines themselves. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 100).apply { + convertMarkup = true + optimal = false + }, + """ + /** + * Tokenizer traverses a Kotlin parse tree (which blessedly contains whitespaces and comments, + * unlike Javac) and constructs a list of 'Tok's. + * + * The google-java-format infra expects newline Toks to be separate from maximal-whitespace Toks, + * but Kotlin emits them together. So, we split them using Java's \R regex matcher. We don't use + * 'split' et al. because we want Toks for the newlines themselves. + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testConvertMarkup() { + // If the markup still contains HTML paragraph separators, separate + // paragraphs + val source = + """ + /** + * This is <b>bold</b>, this is <i>italics</i>, but nothing + * should be converted in `<b>code</b>` or in + * ``` + * <i>preformatted text</i> + * ``` + * And this \` is <b>not code and should be converted</b>. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40).apply { convertMarkup = true }, + """ + /** + * This is **bold**, this is + * *italics*, but nothing should be + * converted in `<b>code</b>` or in + * + * ``` + * <i>preformatted text</i> + * ``` + * + * And this \` is **not code and + * should be converted**. + */ + """ + .trimIndent()) + } + + @Test + fun testFormattingList() { + val source = + """ + /** + * 1. This is a numbered list. + * 2. This is another item. We should be wrapping extra text under the same item. + * 3. This is the third item. + * + * Unordered list: + * * First + * * Second + * * Third + * + * Other alternatives: + * - First + * - Second + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * 1. This is a numbered list. + * 2. This is another item. We + * should be wrapping extra text + * under the same item. + * 3. This is the third item. + * + * Unordered list: + * * First + * * Second + * * Third + * + * Other alternatives: + * - First + * - Second + */ + """ + .trimIndent()) + } + + @Test + fun testList1() { + val source = + """ + /** + * * pre.errorlines: General > Text > Default Text + * * .prefix: XML > Namespace Prefix + * * .attribute: XML > Attribute name + * * .value: XML > Attribute value + * * .tag: XML > Tag name + * * .lineno: For color, General > Code > Line number, Foreground, and for background-color, + * Editor > Gutter background + * * .error: General > Errors and Warnings > Error + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * * pre.errorlines: General > + * Text > Default Text + * * .prefix: XML > Namespace Prefix + * * .attribute: XML > Attribute + * name + * * .value: XML > Attribute value + * * .tag: XML > Tag name + * * .lineno: For color, General > + * Code > Line number, Foreground, + * and for background-color, + * Editor > Gutter background + * * .error: General > Errors and + * Warnings > Error + */ + """ + .trimIndent()) + } + + @Test + fun testIndentedList() { + val source = + """ + /** + * Basic usage: + * 1. Create a configuration via [UastEnvironment.Configuration.create] and mutate it as needed. + * 2. Create a project environment via [UastEnvironment.create]. + * You can create multiple environments in the same process (one for each "module"). + * 3. Call [analyzeFiles] to initialize PSI machinery and precompute resolve information. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * Basic usage: + * 1. Create a configuration via + * [UastEnvironment.Configuration.create] + * and mutate it as needed. + * 2. Create a project environment + * via [UastEnvironment.create]. + * You can create multiple + * environments in the same + * process (one for each + * "module"). + * 3. Call [analyzeFiles] to + * initialize PSI machinery and + * precompute resolve + * information. + */ + """ + .trimIndent()) + } + + @Test + fun testDocTags() { + val source = + """ + /** + * @param configuration the configuration to look up which issues are + * enabled etc from + * @param platforms the platforms applying to this analysis + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * @param configuration the + * configuration to look up which + * issues are enabled etc from + * @param platforms the platforms + * applying to this analysis + */ + """ + .trimIndent()) + } + + @Test + fun testAtInMiddle() { + val source = + """ + /** + * If non-null, this issue can **only** be suppressed with one of the + * given annotations: not with @Suppress, not with @SuppressLint, not + * with lint.xml, not with lintOptions{} and not with baselines. + * + * Test @IntRange and @FloatRange support annotation applied to + * arrays and vargs. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * If non-null, this issue can **only** be suppressed with + * one of the given annotations: not with @Suppress, not + * with @SuppressLint, not with lint.xml, not with lintOptions{} and + * not with baselines. + * + * Test @IntRange and @FloatRange support annotation applied to + * arrays and vargs. + */ + """ + .trimIndent(), + ) + } + + @Test + fun testMaxCommentWidth() { + checkFormatter( + """ + /** + * Returns whether lint should check all warnings, + * including those off by default, or null if + *not configured in this configuration. This is a really really really long sentence which needs to be broken up. + * This is a separate section + * which should be flowed together with the first one. + * *bold* should not be removed even at beginning. + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * Returns whether lint should + * check all warnings, including + * those off by default, or + * null if not configured in + * this configuration. This is + * a really really really long + * sentence which needs to be + * broken up. This is a separate + * section which should be flowed + * together with the first one. + * *bold* should not be removed + * even at beginning. + */ + """ + .trimIndent()) + } + + @Test + fun testHorizontalRuler() { + checkFormatter( + """ + /** + * This is a header. Should appear alone. + * -------------------------------------- + * + * This should not be on the same line as the header. + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * This is a header. Should + * appear alone. + * -------------------------------------- + * This should not be on the same + * line as the header. + */ + """ + .trimIndent(), + verifyDokka = false) + } + + @Test + fun testQuoteOnlyOnFirstLine() { + checkFormatter( + """ + /** + * More: + * > This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * @sample Sample + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * More: + * > This whole paragraph should + * > be treated as a block quote. + * > This whole paragraph should + * > be treated as a block quote. + * > This whole paragraph should + * > be treated as a block quote. + * > This whole paragraph should + * > be treated as a block quote. + * + * @sample Sample + */ + """ + .trimIndent()) + } + + @Test + fun testNoBreakUrl() { + checkFormatter( + """ + /** + * # Design + * The splash screen icon uses the same specifications as + * [Adaptive Icons](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 100), + """ + /** + * # Design + * The splash screen icon uses the same specifications as + * [Adaptive Icons](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + */ + """ + .trimIndent()) + } + + @Test + fun testAsciiArt() { + // Comment from + // https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-master-dev:build-system/integration-test/application/src/test/java/com/android/build/gradle/integration/bundle/DynamicFeatureAndroidTestBuildTest.kt + checkFormatter( + """ + /** + * Base <------------ Middle DF <------------- DF <--------- Android Test DF + * / \ / \ | / \ \ + * v v v v v v \ \ + * appLib sharedLib midLib sharedMidLib featureLib testFeatureLib \ \ + * ^ ^_______________________________________/ / + * |________________________________________________________________/ + * + * DF has a feature-on-feature dep on Middle DF, both depend on Base, Android Test DF is an + * android test variant for DF. + * + * Base depends on appLib and sharedLib. + * Middle DF depends on midLib and sharedMidLib. + * DF depends on featureLib. + * DF also has an android test dependency on testFeatureLib, shared and sharedMidLib. + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * Base <------------ Middle DF <------------- DF <--------- Android Test DF + * / \ / \ | / \ \ + * v v v v v v \ \ + * appLib sharedLib midLib sharedMidLib featureLib testFeatureLib \ \ + * ^ ^_______________________________________/ / + * |________________________________________________________________/ + * + * DF has a feature-on-feature + * dep on Middle DF, both depend + * on Base, Android Test DF is an + * android test variant for DF. + * + * Base depends on appLib and + * sharedLib. Middle DF depends + * on midLib and sharedMidLib. DF + * depends on featureLib. DF also + * has an android test dependency + * on testFeatureLib, shared and + * sharedMidLib. + */ + """ + .trimIndent()) + } + + @Test + fun testAsciiArt2() { + checkFormatter( + """ + /** + * +-> lib1 + * | + * feature1 ---+-> javalib1 + * | + * +-> baseModule + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * +-> lib1 + * | + * feature1 ---+-> javalib1 + * | + * +-> baseModule + */ + """ + .trimIndent()) + } + + @Test + fun testAsciiArt3() { + val source = + """ + /** + * This test creates a layout of this shape: + * + * --------------- + * | t | | + * | | | + * | |-------| | + * | | t | | + * | | | | + * | | | | + * |--| |-------| + * | | | t | + * | | | | + * | | | | + * | |--| | + * | | | + * --------------- + * + * There are 3 staggered children and 3 pointers, the first is on child 1, the second is on + * child 2 in a space that overlaps child 1, and the third is in a space in child 3 that + * overlaps child 2. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 30), + """ + /** + * This test creates a layout of + * this shape: + * --------------- + * | t | | | | | | |-------| | | + * | t | | | | | | | | | | |--| + * |-------| | | | t | | | | | | + * | | | | |--| | | | | + * --------------- + * There are 3 staggered children + * and 3 pointers, the first is + * on child 1, the second is + * on child 2 in a space that + * overlaps child 1, and the + * third is in a space in child + * 3 that overlaps child 2. + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testBrokenAsciiArt() { + // The first illustration has indentation 3, not 4, so isn't preformatted. + // The formatter will garble this -- but so will Dokka! + // From androidx' TwoDimensionalFocusTraversalOutTest.kt + checkFormatter( + """ + /** + * ___________________________ + * | grandparent | + * | _____________________ | + * | | parent | | + * | | _______________ | | ____________ + * | | | focusedItem | | | | nextItem | + * | | |______________| | | |___________| + * | |____________________| | + * |__________________________| + * + * __________________________ + * | grandparent | + * | ____________________ | + * | | parent | | + * | | ______________ | | + * | | | focusedItem | | | + * | | |_____________| | | + * | |___________________| | + * |_________________________| + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, 100), + """ + /** + * ___________________________ | grandparent | | _____________________ | | | parent + * | | | | _______________ | | ____________ | | | focusedItem | | | | nextItem | | | + * |______________| | | |___________| | |____________________| | |__________________________| + * + * __________________________ + * | grandparent | + * | ____________________ | + * | | parent | | + * | | ______________ | | + * | | | focusedItem | | | + * | | |_____________| | | + * | |___________________| | + * |_________________________| + */ + """ + .trimIndent(), + verifyDokka = false) + } + + @Test + fun testHtmlLists() { + checkFormatter( + """ + /** + * <ul> + * <li>Incremental merge will never clean the output. + * <li>The inputs must be able to tell which changes to relative files have been made. + * <li>Intermediate state must be saved between merges. + * </ul> + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 60), + """ + /** + * <ul> + * <li>Incremental merge will never clean the output. + * <li>The inputs must be able to tell which changes to + * relative files have been made. + * <li>Intermediate state must be saved between merges. + * </ul> + */ + """ + .trimIndent()) + } + + @Test + fun testVariousMarkup() { + val source = + """ + /** + * This document contains a bunch of markup examples + * that I will use + * to verify that things are handled + * correctly via markdown. + * + * This is a header. Should appear alone. + * -------------------------------------- + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * - + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * ====================================== + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * = + * This should not be on the same line as the header. + * Note that we don't treat this as a header + * because it's not on its own line. Instead + * it's considered a separating line. + * --- + * More text. Should not be on the previous line. + * + * --- This usage of --- where it's not on its own + * line should not be used as a header or separator line. + * + * List stuff: + * 1. First item + * 2. Second item + * 3. Third item + * + * # Text styles # + * **Bold**, *italics*. \*Not italics\*. + * + * ## More text styles + * ~~strikethrough~~, _underlined_. + * + * ### Blockquotes # + * + * More: + * > This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * This whole paragraph should be treated as a block quote. + * + * ### Lists + * Plus lists: + * + First + * + Second + * + Third + * + * Dash lists: + * - First + * - Second + * - Third + * + * List items with multiple paragraphs: + * + * * This is my list item. It has + * text on many lines. + * + * This is a continuation of the first bullet. + * * And this is the second. + * + * ### Code blocks in list items + * + * Escapes: I should look for cases where I place a number followed + * by a period (or asterisk) at the beginning of a line and if so, + * escape it: + * + * The meaning of life: + * 42\. This doesn't seem to work in IntelliJ's markdown formatter. + * + * ### Horizontal rules + * ********* + * --------- + * *** + * * * * + * - - - + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 100), + """ + /** + * This document contains a bunch of markup examples that I will use to verify that things are + * handled correctly via markdown. + * + * This is a header. Should appear alone. + * -------------------------------------- + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * - + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * ====================================== + * This should not be on the same line as the header. + * + * This is a header. Should appear alone. + * = + * This should not be on the same line as the header. Note that we don't treat this as a header + * because it's not on its own line. Instead it's considered a separating line. + * --- + * More text. Should not be on the previous line. + * + * --- This usage of --- where it's not on its own line should not be used as a header or + * separator line. + * + * List stuff: + * 1. First item + * 2. Second item + * 3. Third item + * + * # Text styles # + * **Bold**, *italics*. \*Not italics\*. + * + * ## More text styles + * ~~strikethrough~~, _underlined_. + * + * ### Blockquotes # + * + * More: + * > This whole paragraph should be treated as a block quote. This whole paragraph should be + * > treated as a block quote. This whole paragraph should be treated as a block quote. This whole + * > paragraph should be treated as a block quote. + * + * ### Lists + * Plus lists: + * + First + * + Second + * + Third + * + * Dash lists: + * - First + * - Second + * - Third + * + * List items with multiple paragraphs: + * * This is my list item. It has text on many lines. + * + * This is a continuation of the first bullet. + * * And this is the second. + * + * ### Code blocks in list items + * + * Escapes: I should look for cases where I place a number followed by a period (or asterisk) at + * the beginning of a line and if so, escape it: + * + * The meaning of life: 42\. This doesn't seem to work in IntelliJ's markdown formatter. + * + * ### Horizontal rules + * ********* + * --------- + * *** + * * * * + * - - - + */ + """ + .trimIndent()) + } + + @Test + fun testLineComments() { + val source = + """ + // + // Information about a request to run lint. + // + // **NOTE: This is not a public or final API; if you rely on this be prepared + // to adjust your code for the next tools release.** + // + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + // Information about a request to + // run lint. + // + // **NOTE: This is not a public or + // final API; if you rely on this be + // prepared to adjust your code for + // the next tools release.** + """ + .trimIndent()) + } + + @Test + fun testMoreLineComments() { + val source = + """ + // Do not clean + // this + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(70), + """ + // Do not clean this + """ + .trimIndent()) + } + + @Test + fun testListContinuations() { + val source = + """ + /** + * * This is my list item. It has + * text on many lines. + * + * This is a continuation of the first bullet. + * * And this is the second. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * * This is my list item. It has + * text on many lines. + * + * This is a continuation of the + * first bullet. + * * And this is the second. + */ + """ + .trimIndent()) + } + + @Test + fun testListContinuations2() { + val source = + "/**\n" + + """ + List items with multiple paragraphs: + + * This is my list item. It has + text on many lines. + + This is a continuation of the first bullet. + * And this is the second. + """ + .trimIndent() + .split("\n") + .joinToString(separator = "\n") { " * $it".trimEnd() } + + "\n */" + + checkFormatter( + source, + KDocFormattingOptions(100), + """ + /** + * List items with multiple paragraphs: + * * This is my list item. It has text on many lines. + * + * This is a continuation of the first bullet. + * * And this is the second. + */ + """ + .trimIndent()) + } + + @Test + fun testAccidentalHeader() { + val source = + """ + /** + * Constructs a simplified version of the internal JVM description of the given method. This is + * in the same format as {@link #getMethodDescription} above, the difference being we don't have + * the actual PSI for the method type, we just construct the signature from the [method] name, + * the list of [argumentTypes] and optionally include the [returnType]. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + // Note how this places the "#" in column 0 which will then + // be re-interpreted as a header next time we format it! + // Idea: @{link #} should become {@link#} or with a nbsp; + """ + /** + * Constructs a simplified version of the internal JVM + * description of the given method. This is in the same format as + * [getMethodDescription] above, the difference being we don't + * have the actual PSI for the method type, we just construct the + * signature from the [method] name, the list of [argumentTypes] and + * optionally include the [returnType]. + */ + """ + .trimIndent(), + // {@link} text is not rendered by dokka when it cannot resolve the symbols + verifyDokka = false) + } + + @Test + fun testTODO() { + val source = + """ + /** + * Adds the given dependency graph (the output of the Gradle dependency task) + * to be constructed when mocking a Gradle model for this project. + * <p> + * To generate this, run for example + * <pre> + * ./gradlew :app:dependencies + * </pre> + * and then look at the debugCompileClasspath (or other graph that you want + * to model). + * TODO: Adds the given dependency graph (the output of the Gradle dependency task) + * to be constructed when mocking a Gradle model for this project. + * TODO: More stuff to do here + * @param dependencyGraph the graph description + * @return this for constructor chaining + * TODO: Consider looking at the localization="suggested" attribute in + * the platform attrs.xml to catch future recommended attributes. + * TODO: Also adds the given dependency graph (the output of the Gradle dependency task) + * to be constructed when mocking a Gradle model for this project. + * TODO(b/144576310): Cover multi-module search. + * Searching in the search bar should show an option to change module if there are resources in it. + * TODO(myldap): Cover filter usage. Eg: Look for a framework resource by enabling its filter. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72).apply { orderDocTags = true }, + // Note how this places the "#" in column 0 which will then + // be re-interpreted as a header next time we format it! + // Idea: @{link #} should become {@link#} or with a nbsp; + """ + /** + * Adds the given dependency graph (the output of the Gradle + * dependency task) to be constructed when mocking a Gradle model + * for this project. + * + * To generate this, run for example + * + * ``` + * ./gradlew :app:dependencies + * ``` + * + * and then look at the debugCompileClasspath (or other graph that + * you want to model). + * + * @param dependencyGraph the graph description + * @return this for constructor chaining + * + * TODO: Adds the given dependency graph (the output of the Gradle + * dependency task) to be constructed when mocking a Gradle model + * for this project. + * TODO: More stuff to do here + * TODO: Consider looking at the localization="suggested" attribute + * in the platform attrs.xml to catch future recommended + * attributes. + * TODO: Also adds the given dependency graph (the output of the + * Gradle dependency task) to be constructed when mocking a Gradle + * model for this project. + * TODO(b/144576310): Cover multi-module search. Searching in the + * search bar should show an option to change module if there are + * resources in it. + * TODO(myldap): Cover filter usage. Eg: Look for a framework + * resource by enabling its filter. + */ + """ + .trimIndent(), + // We indent TO-DO text deliberately, though this changes the structure to + // make each item have its own paragraph which doesn't happen by default. + // Working as intended. + verifyDokka = false) + } + + @Test + fun testReorderTags() { + val source = + """ + /** + * Constructs a new location range for the given file, from start to + * end. If the length of the range is not known, end may be null. + * + * @return Something + * @sample Other + * @param file the associated file (but see the documentation for + * [Location.file] for more information on what the file + * represents) + * + * @param end the ending position, or null + * @param[ start ] the starting position, or null + * @see More + */ + """ + .trimIndent() + checkFormatter( + FormattingTask( + KDocFormattingOptions(72), + source, + " ", + orderedParameterNames = listOf("file", "start", "end")), + // Note how this places the "#" in column 0 which will then + // be re-interpreted as a header next time we format it! + // Idea: @{link #} should become {@link#} or with a nbsp; + """ + /** + * Constructs a new location range for the given file, from start to + * end. If the length of the range is not known, end may be null. + * + * @param file the associated file (but see the documentation for + * [Location.file] for more information on what the file + * represents) + * @param start the starting position, or null + * @param end the ending position, or null + * @return Something + * @sample Other + * @see More + */ + """ + .trimIndent(), + ) + } + + @Test + fun testKDocOrdering() { + // From AndroidX' + // frameworks/support/biometric/biometric-ktx/src/main/java/androidx/biometric/auth/CredentialAuthExtensions.kt + val source = + """ + /** + * Shows an authentication prompt to the user. + * + * @param host A wrapper for the component that will host the prompt. + * @param crypto A cryptographic object to be associated with this authentication. + * + * @return [AuthenticationResult] for a successful authentication. + * + * @throws AuthPromptErrorException when an unrecoverable error has been encountered and + * authentication has stopped. + * @throws AuthPromptFailureException when an authentication attempt by the user has been rejected. + * + * @see CredentialAuthPrompt.authenticate( + * AuthPromptHost host, + * BiometricPrompt.CryptoObject, + * AuthPromptCallback + * ) + * + * @sample androidx.biometric.samples.auth.credentialAuth + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Shows an authentication prompt to the user. + * + * @param host A wrapper for the component that will host the prompt. + * @param crypto A cryptographic object to be associated with this + * authentication. + * @return [AuthenticationResult] for a successful authentication. + * @throws AuthPromptErrorException when an unrecoverable error has been + * encountered and authentication has stopped. + * @throws AuthPromptFailureException when an authentication attempt by + * the user has been rejected. + * @sample androidx.biometric.samples.auth.credentialAuth + * @see CredentialAuthPrompt.authenticate( AuthPromptHost host, + * BiometricPrompt.CryptoObject, AuthPromptCallback ) + */ + """ + .trimIndent(), + indent = "", + ) + } + + @Test + fun testHtml() { + // Comment from lint's SourceCodeScanner class doc. Tests a number of + // things -- markup conversion (<h2> to ##, <p> to blank lines), list item + // indentation, trimming blank lines from the end, etc. + val source = + """ + /** + * Interface to be implemented by lint detectors that want to analyze + * Java source files (or other similar source files, such as Kotlin files.) + * <p> + * There are several different common patterns for detecting issues: + * <ul> + * <li> Checking calls to a given method. For this see + * {@link #getApplicableMethodNames()} and + * {@link #visitMethodCall(JavaContext, UCallExpression, PsiMethod)}</li> + * <li> Instantiating a given class. For this, see + * {@link #getApplicableConstructorTypes()} and + * {@link #visitConstructor(JavaContext, UCallExpression, PsiMethod)}</li> + * <li> Referencing a given constant. For this, see + * {@link #getApplicableReferenceNames()} and + * {@link #visitReference(JavaContext, UReferenceExpression, PsiElement)}</li> + * <li> Extending a given class or implementing a given interface. + * For this, see {@link #applicableSuperClasses()} and + * {@link #visitClass(JavaContext, UClass)}</li> + * <li> More complicated scenarios: perform a general AST + * traversal with a visitor. In this case, first tell lint which + * AST node types you're interested in with the + * {@link #getApplicableUastTypes()} method, and then provide a + * {@link UElementHandler} from the {@link #createUastHandler(JavaContext)} + * where you override the various applicable handler methods. This is + * done rather than a general visitor from the root node to avoid + * having to have every single lint detector (there are hundreds) do a full + * tree traversal on its own.</li> + * </ul> + * <p> + * {@linkplain SourceCodeScanner} exposes the UAST API to lint checks. + * UAST is short for "Universal AST" and is an abstract syntax tree library + * which abstracts away details about Java versus Kotlin versus other similar languages + * and lets the client of the library access the AST in a unified way. + * <p> + * UAST isn't actually a full replacement for PSI; it <b>augments</b> PSI. + * Essentially, UAST is used for the <b>inside</b> of methods (e.g. method bodies), + * and things like field initializers. PSI continues to be used at the outer + * level: for packages, classes, and methods (declarations and signatures). + * There are also wrappers around some of these for convenience. + * <p> + * The {@linkplain SourceCodeScanner} interface reflects this fact. For example, + * when you indicate that you want to check calls to a method named {@code foo}, + * the call site node is a UAST node (in this case, {@link UCallExpression}, + * but the called method itself is a {@link PsiMethod}, since that method + * might be anywhere (including in a library that we don't have source for, + * so UAST doesn't make sense.) + * <p> + * <h2>Migrating JavaPsiScanner to SourceCodeScanner</h2> + * As described above, PSI is still used, so a lot of code will remain the + * same. For example, all resolve methods, including those in UAST, will + * continue to return PsiElement, not necessarily a UElement. For example, + * if you resolve a method call or field reference, you'll get a + * {@link PsiMethod} or {@link PsiField} back. + * <p> + * However, the visitor methods have all changed, generally to change + * to UAST types. For example, the signature + * {@link JavaPsiScanner#visitMethodCall(JavaContext, JavaElementVisitor, PsiMethodCallExpression, PsiMethod)} + * should be changed to {@link SourceCodeScanner#visitMethodCall(JavaContext, UCallExpression, PsiMethod)}. + * <p> + * Similarly, replace {@link JavaPsiScanner#createPsiVisitor} with {@link SourceCodeScanner#createUastHandler}, + * {@link JavaPsiScanner#getApplicablePsiTypes()} with {@link SourceCodeScanner#getApplicableUastTypes()}, etc. + * <p> + * There are a bunch of new methods on classes like {@link JavaContext} which lets + * you pass in a {@link UElement} to match the existing {@link PsiElement} methods. + * <p> + * If you have code which does something specific with PSI classes, + * the following mapping table in alphabetical order might be helpful, since it lists the + * corresponding UAST classes. + * <table> + * <caption>Mapping between PSI and UAST classes</caption> + * <tr><th>PSI</th><th>UAST</th></tr> + * <tr><th>com.intellij.psi.</th><th>org.jetbrains.uast.</th></tr> + * <tr><td>IElementType</td><td>UastBinaryOperator</td></tr> + * <tr><td>PsiAnnotation</td><td>UAnnotation</td></tr> + * <tr><td>PsiAnonymousClass</td><td>UAnonymousClass</td></tr> + * <tr><td>PsiArrayAccessExpression</td><td>UArrayAccessExpression</td></tr> + * <tr><td>PsiBinaryExpression</td><td>UBinaryExpression</td></tr> + * <tr><td>PsiCallExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiCatchSection</td><td>UCatchClause</td></tr> + * <tr><td>PsiClass</td><td>UClass</td></tr> + * <tr><td>PsiClassObjectAccessExpression</td><td>UClassLiteralExpression</td></tr> + * <tr><td>PsiConditionalExpression</td><td>UIfExpression</td></tr> + * <tr><td>PsiDeclarationStatement</td><td>UDeclarationsExpression</td></tr> + * <tr><td>PsiDoWhileStatement</td><td>UDoWhileExpression</td></tr> + * <tr><td>PsiElement</td><td>UElement</td></tr> + * <tr><td>PsiExpression</td><td>UExpression</td></tr> + * <tr><td>PsiForeachStatement</td><td>UForEachExpression</td></tr> + * <tr><td>PsiIdentifier</td><td>USimpleNameReferenceExpression</td></tr> + * <tr><td>PsiIfStatement</td><td>UIfExpression</td></tr> + * <tr><td>PsiImportStatement</td><td>UImportStatement</td></tr> + * <tr><td>PsiImportStaticStatement</td><td>UImportStatement</td></tr> + * <tr><td>PsiJavaCodeReferenceElement</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiLiteral</td><td>ULiteralExpression</td></tr> + * <tr><td>PsiLocalVariable</td><td>ULocalVariable</td></tr> + * <tr><td>PsiMethod</td><td>UMethod</td></tr> + * <tr><td>PsiMethodCallExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiNameValuePair</td><td>UNamedExpression</td></tr> + * <tr><td>PsiNewExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiParameter</td><td>UParameter</td></tr> + * <tr><td>PsiParenthesizedExpression</td><td>UParenthesizedExpression</td></tr> + * <tr><td>PsiPolyadicExpression</td><td>UPolyadicExpression</td></tr> + * <tr><td>PsiPostfixExpression</td><td>UPostfixExpression or UUnaryExpression</td></tr> + * <tr><td>PsiPrefixExpression</td><td>UPrefixExpression or UUnaryExpression</td></tr> + * <tr><td>PsiReference</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiReference</td><td>UResolvable</td></tr> + * <tr><td>PsiReferenceExpression</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiReturnStatement</td><td>UReturnExpression</td></tr> + * <tr><td>PsiSuperExpression</td><td>USuperExpression</td></tr> + * <tr><td>PsiSwitchLabelStatement</td><td>USwitchClauseExpression</td></tr> + * <tr><td>PsiSwitchStatement</td><td>USwitchExpression</td></tr> + * <tr><td>PsiThisExpression</td><td>UThisExpression</td></tr> + * <tr><td>PsiThrowStatement</td><td>UThrowExpression</td></tr> + * <tr><td>PsiTryStatement</td><td>UTryExpression</td></tr> + * <tr><td>PsiTypeCastExpression</td><td>UBinaryExpressionWithType</td></tr> + * <tr><td>PsiWhileStatement</td><td>UWhileExpression</td></tr> + * </table> + * Note however that UAST isn't just a "renaming of classes"; there are + * some changes to the structure of the AST as well. Particularly around + * calls. + * + * <h3>Parents</h3> + * In UAST, you get your parent {@linkplain UElement} by calling + * {@code getUastParent} instead of {@code getParent}. This is to avoid + * method name clashes on some elements which are both UAST elements + * and PSI elements at the same time - such as {@link UMethod}. + * <h3>Children</h3> + * When you're going in the opposite direction (e.g. you have a {@linkplain PsiMethod} + * and you want to look at its content, you should <b>not</b> use + * {@link PsiMethod#getBody()}. This will only give you the PSI child content, + * which won't work for example when dealing with Kotlin methods. + * Normally lint passes you the {@linkplain UMethod} which you should be procesing + * instead. But if for some reason you need to look up the UAST method + * body from a {@linkplain PsiMethod}, use this: + * <pre> + * UastContext context = UastUtils.getUastContext(element); + * UExpression body = context.getMethodBody(method); + * </pre> + * Similarly if you have a {@link PsiField} and you want to look up its field + * initializer, use this: + * <pre> + * UastContext context = UastUtils.getUastContext(element); + * UExpression initializer = context.getInitializerBody(field); + * </pre> + * + * <h3>Call names</h3> + * In PSI, a call was represented by a PsiCallExpression, and to get to things + * like the method called or to the operand/qualifier, you'd first need to get + * the "method expression". In UAST there is no method expression and this + * information is available directly on the {@linkplain UCallExpression} element. + * Therefore, here's how you'd change the code: + * <pre> + * < call.getMethodExpression().getReferenceName(); + * --- + * > call.getMethodName() + * </pre> + * <h3>Call qualifiers</h3> + * Similarly, + * <pre> + * < call.getMethodExpression().getQualifierExpression(); + * --- + * > call.getReceiver() + * </pre> + * <h3>Call arguments</h3> + * PSI had a separate PsiArgumentList element you had to look up before you could + * get to the actual arguments, as an array. In UAST these are available directly on + * the call, and are represented as a list instead of an array. + * <pre> + * < PsiExpression[] args = call.getArgumentList().getExpressions(); + * --- + * > List<UExpression> args = call.getValueArguments(); + * </pre> + * Typically you also need to go through your code and replace array access, + * arg\[i], with list access, {@code arg.get(i)}. Or in Kotlin, just arg\[i]... + * + * <h3>Instanceof</h3> + * You may have code which does something like "parent instanceof PsiAssignmentExpression" + * to see if something is an assignment. Instead, use one of the many utilities + * in {@link UastExpressionUtils} - such as {@link UastExpressionUtils#isAssignment(UElement)}. + * Take a look at all the methods there now - there are methods for checking whether + * a call is a constructor, whether an expression is an array initializer, etc etc. + * + * <h3>Android Resources</h3> + * Don't do your own AST lookup to figure out if something is a reference to + * an Android resource (e.g. see if the class refers to an inner class of a class + * named "R" etc.) There is now a new utility class which handles this: + * {@link ResourceReference}. Here's an example of code which has a {@link UExpression} + * and wants to know it's referencing a R.styleable resource: + * <pre> + * ResourceReference reference = ResourceReference.get(expression); + * if (reference == null || reference.getType() != ResourceType.STYLEABLE) { + * return; + * } + * ... + * </pre> + * + * <h3>Binary Expressions</h3> + * If you had been using {@link PsiBinaryExpression} for things like checking comparator + * operators or arithmetic combination of operands, you can replace this with + * {@link UBinaryExpression}. <b>But you normally shouldn't; you should use + * {@link UPolyadicExpression} instead</b>. A polyadic expression is just like a binary + * expression, but possibly with more than two terms. With the old parser backend, + * an expression like "A + B + C" would be represented by nested binary expressions + * (first A + B, then a parent element which combined that binary expression with C). + * However, this will now be provided as a {@link UPolyadicExpression} instead. And + * the binary case is handled trivially without the need to special case it. + * <h3>Method name changes</h3> + * The following table maps some common method names and what their corresponding + * names are in UAST. + * <table> + * <caption>Mapping between PSI and UAST method names</caption></caption> + * <tr><th>PSI</th><th>UAST</th></tr> + * <tr><td>getArgumentList</td><td>getValueArguments</td></tr> + * <tr><td>getCatchSections</td><td>getCatchClauses</td></tr> + * <tr><td>getDeclaredElements</td><td>getDeclarations</td></tr> + * <tr><td>getElseBranch</td><td>getElseExpression</td></tr> + * <tr><td>getInitializer</td><td>getUastInitializer</td></tr> + * <tr><td>getLExpression</td><td>getLeftOperand</td></tr> + * <tr><td>getOperationTokenType</td><td>getOperator</td></tr> + * <tr><td>getOwner</td><td>getUastParent</td></tr> + * <tr><td>getParent</td><td>getUastParent</td></tr> + * <tr><td>getRExpression</td><td>getRightOperand</td></tr> + * <tr><td>getReturnValue</td><td>getReturnExpression</td></tr> + * <tr><td>getText</td><td>asSourceString</td></tr> + * <tr><td>getThenBranch</td><td>getThenExpression</td></tr> + * <tr><td>getType</td><td>getExpressionType</td></tr> + * <tr><td>getTypeParameters</td><td>getTypeArguments</td></tr> + * <tr><td>resolveMethod</td><td>resolve</td></tr> + * </table> + * <h3>Handlers versus visitors</h3> + * If you are processing a method on your own, or even a full class, you should switch + * from JavaRecursiveElementVisitor to AbstractUastVisitor. + * However, most lint checks don't do their own full AST traversal; they instead + * participate in a shared traversal of the tree, registering element types they're + * interested with using {@link #getApplicableUastTypes()} and then providing + * a visitor where they implement the corresponding visit methods. However, from + * these visitors you should <b>not</b> be calling super.visitX. To remove this + * whole confusion, lint now provides a separate class, {@link UElementHandler}. + * For the shared traversal, just provide this handler instead and implement the + * appropriate visit methods. It will throw an error if you register element types + * in {@linkplain #getApplicableUastTypes()} that you don't override. + * + * <p> + * <h3>Migrating JavaScanner to SourceCodeScanner</h3> + * First read the javadoc on how to convert from the older {@linkplain JavaScanner} + * interface over to {@linkplain JavaPsiScanner}. While {@linkplain JavaPsiScanner} is itself + * deprecated, it's a lot closer to {@link SourceCodeScanner} so a lot of the same concepts + * apply; then follow the above section. + * <p> + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(120, 120), + """ + /** + * Interface to be implemented by lint detectors that want to analyze Java source files (or other similar source + * files, such as Kotlin files.) + * + * There are several different common patterns for detecting issues: + * <ul> + * <li> Checking calls to a given method. For this see [getApplicableMethodNames] and [visitMethodCall]</li> + * <li> Instantiating a given class. For this, see [getApplicableConstructorTypes] and [visitConstructor]</li> + * <li> Referencing a given constant. For this, see [getApplicableReferenceNames] and [visitReference]</li> + * <li> Extending a given class or implementing a given interface. For this, see [applicableSuperClasses] and + * [visitClass]</li> + * <li> More complicated scenarios: perform a general AST traversal with a visitor. In this case, first tell lint + * which AST node types you're interested in with the [getApplicableUastTypes] method, and then provide a + * [UElementHandler] from the [createUastHandler] where you override the various applicable handler methods. This + * is done rather than a general visitor from the root node to avoid having to have every single lint detector + * (there are hundreds) do a full tree traversal on its own.</li> + * </ul> + * + * {@linkplain SourceCodeScanner} exposes the UAST API to lint checks. UAST is short for "Universal AST" and is an + * abstract syntax tree library which abstracts away details about Java versus Kotlin versus other similar languages + * and lets the client of the library access the AST in a unified way. + * + * UAST isn't actually a full replacement for PSI; it **augments** PSI. Essentially, UAST is used for the **inside** + * of methods (e.g. method bodies), and things like field initializers. PSI continues to be used at the outer level: + * for packages, classes, and methods (declarations and signatures). There are also wrappers around some of these + * for convenience. + * + * The {@linkplain SourceCodeScanner} interface reflects this fact. For example, when you indicate that you want to + * check calls to a method named {@code foo}, the call site node is a UAST node (in this case, [UCallExpression], + * but the called method itself is a [PsiMethod], since that method might be anywhere (including in a library that + * we don't have source for, so UAST doesn't make sense.) + * + * ## Migrating JavaPsiScanner to SourceCodeScanner + * As described above, PSI is still used, so a lot of code will remain the same. For example, all resolve methods, + * including those in UAST, will continue to return PsiElement, not necessarily a UElement. For example, if you + * resolve a method call or field reference, you'll get a [PsiMethod] or [PsiField] back. + * + * However, the visitor methods have all changed, generally to change to UAST types. For example, the signature + * [JavaPsiScanner.visitMethodCall] should be changed to [SourceCodeScanner.visitMethodCall]. + * + * Similarly, replace [JavaPsiScanner.createPsiVisitor] with [SourceCodeScanner.createUastHandler], + * [JavaPsiScanner.getApplicablePsiTypes] with [SourceCodeScanner.getApplicableUastTypes], etc. + * + * There are a bunch of new methods on classes like [JavaContext] which lets you pass in a [UElement] to match the + * existing [PsiElement] methods. + * + * If you have code which does something specific with PSI classes, the following mapping table in alphabetical + * order might be helpful, since it lists the corresponding UAST classes. + * <table> + * <caption>Mapping between PSI and UAST classes</caption> + * <tr><th>PSI</th><th>UAST</th></tr> + * <tr><th>com.intellij.psi.</th><th>org.jetbrains.uast.</th></tr> + * <tr><td>IElementType</td><td>UastBinaryOperator</td></tr> + * <tr><td>PsiAnnotation</td><td>UAnnotation</td></tr> + * <tr><td>PsiAnonymousClass</td><td>UAnonymousClass</td></tr> + * <tr><td>PsiArrayAccessExpression</td><td>UArrayAccessExpression</td></tr> + * <tr><td>PsiBinaryExpression</td><td>UBinaryExpression</td></tr> + * <tr><td>PsiCallExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiCatchSection</td><td>UCatchClause</td></tr> + * <tr><td>PsiClass</td><td>UClass</td></tr> + * <tr><td>PsiClassObjectAccessExpression</td><td>UClassLiteralExpression</td></tr> + * <tr><td>PsiConditionalExpression</td><td>UIfExpression</td></tr> + * <tr><td>PsiDeclarationStatement</td><td>UDeclarationsExpression</td></tr> + * <tr><td>PsiDoWhileStatement</td><td>UDoWhileExpression</td></tr> + * <tr><td>PsiElement</td><td>UElement</td></tr> + * <tr><td>PsiExpression</td><td>UExpression</td></tr> + * <tr><td>PsiForeachStatement</td><td>UForEachExpression</td></tr> + * <tr><td>PsiIdentifier</td><td>USimpleNameReferenceExpression</td></tr> + * <tr><td>PsiIfStatement</td><td>UIfExpression</td></tr> + * <tr><td>PsiImportStatement</td><td>UImportStatement</td></tr> + * <tr><td>PsiImportStaticStatement</td><td>UImportStatement</td></tr> + * <tr><td>PsiJavaCodeReferenceElement</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiLiteral</td><td>ULiteralExpression</td></tr> + * <tr><td>PsiLocalVariable</td><td>ULocalVariable</td></tr> + * <tr><td>PsiMethod</td><td>UMethod</td></tr> + * <tr><td>PsiMethodCallExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiNameValuePair</td><td>UNamedExpression</td></tr> + * <tr><td>PsiNewExpression</td><td>UCallExpression</td></tr> + * <tr><td>PsiParameter</td><td>UParameter</td></tr> + * <tr><td>PsiParenthesizedExpression</td><td>UParenthesizedExpression</td></tr> + * <tr><td>PsiPolyadicExpression</td><td>UPolyadicExpression</td></tr> + * <tr><td>PsiPostfixExpression</td><td>UPostfixExpression or UUnaryExpression</td></tr> + * <tr><td>PsiPrefixExpression</td><td>UPrefixExpression or UUnaryExpression</td></tr> + * <tr><td>PsiReference</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiReference</td><td>UResolvable</td></tr> + * <tr><td>PsiReferenceExpression</td><td>UReferenceExpression</td></tr> + * <tr><td>PsiReturnStatement</td><td>UReturnExpression</td></tr> + * <tr><td>PsiSuperExpression</td><td>USuperExpression</td></tr> + * <tr><td>PsiSwitchLabelStatement</td><td>USwitchClauseExpression</td></tr> + * <tr><td>PsiSwitchStatement</td><td>USwitchExpression</td></tr> + * <tr><td>PsiThisExpression</td><td>UThisExpression</td></tr> + * <tr><td>PsiThrowStatement</td><td>UThrowExpression</td></tr> + * <tr><td>PsiTryStatement</td><td>UTryExpression</td></tr> + * <tr><td>PsiTypeCastExpression</td><td>UBinaryExpressionWithType</td></tr> + * <tr><td>PsiWhileStatement</td><td>UWhileExpression</td></tr> </table> Note however that UAST isn't just a + * "renaming of classes"; there are some changes to the structure of the AST as well. Particularly around calls. + * + * ### Parents + * In UAST, you get your parent {@linkplain UElement} by calling {@code getUastParent} instead of {@code getParent}. + * This is to avoid method name clashes on some elements which are both UAST elements and PSI elements at the same + * time - such as [UMethod]. + * + * ### Children + * When you're going in the opposite direction (e.g. you have a {@linkplain PsiMethod} and you want to look at its + * content, you should **not** use [PsiMethod.getBody]. This will only give you the PSI child content, which won't + * work for example when dealing with Kotlin methods. Normally lint passes you the {@linkplain UMethod} which you + * should be procesing instead. But if for some reason you need to look up the UAST method body from a {@linkplain + * PsiMethod}, use this: + * ``` + * UastContext context = UastUtils.getUastContext(element); + * UExpression body = context.getMethodBody(method); + * ``` + * + * Similarly if you have a [PsiField] and you want to look up its field initializer, use this: + * ``` + * UastContext context = UastUtils.getUastContext(element); + * UExpression initializer = context.getInitializerBody(field); + * ``` + * + * ### Call names + * In PSI, a call was represented by a PsiCallExpression, and to get to things like the method called or to the + * operand/qualifier, you'd first need to get the "method expression". In UAST there is no method expression and + * this information is available directly on the {@linkplain UCallExpression} element. Therefore, here's how you'd + * change the code: + * ``` + * < call.getMethodExpression().getReferenceName(); + * --- + * > call.getMethodName() + * ``` + * + * ### Call qualifiers + * Similarly, + * ``` + * < call.getMethodExpression().getQualifierExpression(); + * --- + * > call.getReceiver() + * ``` + * + * ### Call arguments + * PSI had a separate PsiArgumentList element you had to look up before you could get to the actual arguments, as an + * array. In UAST these are available directly on the call, and are represented as a list instead of an array. + * + * ``` + * < PsiExpression[] args = call.getArgumentList().getExpressions(); + * --- + * > List<UExpression> args = call.getValueArguments(); + * ``` + * + * Typically you also need to go through your code and replace array access, arg\[i], with list access, {@code + * arg.get(i)}. Or in Kotlin, just arg\[i]... + * + * ### Instanceof + * You may have code which does something like "parent instanceof PsiAssignmentExpression" to see if + * something is an assignment. Instead, use one of the many utilities in [UastExpressionUtils] - such + * as [UastExpressionUtils.isAssignment]. Take a look at all the methods there now - there are methods + * for checking whether a call is a constructor, whether an expression is an array initializer, etc etc. + * + * ### Android Resources + * Don't do your own AST lookup to figure out if something is a reference to an Android resource (e.g. see if the + * class refers to an inner class of a class named "R" etc.) There is now a new utility class which handles this: + * [ResourceReference]. Here's an example of code which has a [UExpression] and wants to know it's referencing a + * R.styleable resource: + * ``` + * ResourceReference reference = ResourceReference.get(expression); + * if (reference == null || reference.getType() != ResourceType.STYLEABLE) { + * return; + * } + * ... + * ``` + * + * ### Binary Expressions + * If you had been using [PsiBinaryExpression] for things like checking comparator operators or arithmetic + * combination of operands, you can replace this with [UBinaryExpression]. **But you normally shouldn't; you should + * use [UPolyadicExpression] instead**. A polyadic expression is just like a binary expression, but possibly with + * more than two terms. With the old parser backend, an expression like "A + B + C" would be represented by nested + * binary expressions (first A + B, then a parent element which combined that binary expression with C). However, + * this will now be provided as a [UPolyadicExpression] instead. And the binary case is handled trivially without + * the need to special case it. + * + * ### Method name changes + * The following table maps some common method names and what their corresponding names are in UAST. + * <table> + * <caption>Mapping between PSI and UAST method names</caption></caption> + * <tr><th>PSI</th><th>UAST</th></tr> + * <tr><td>getArgumentList</td><td>getValueArguments</td></tr> + * <tr><td>getCatchSections</td><td>getCatchClauses</td></tr> + * <tr><td>getDeclaredElements</td><td>getDeclarations</td></tr> + * <tr><td>getElseBranch</td><td>getElseExpression</td></tr> + * <tr><td>getInitializer</td><td>getUastInitializer</td></tr> + * <tr><td>getLExpression</td><td>getLeftOperand</td></tr> + * <tr><td>getOperationTokenType</td><td>getOperator</td></tr> + * <tr><td>getOwner</td><td>getUastParent</td></tr> + * <tr><td>getParent</td><td>getUastParent</td></tr> + * <tr><td>getRExpression</td><td>getRightOperand</td></tr> + * <tr><td>getReturnValue</td><td>getReturnExpression</td></tr> + * <tr><td>getText</td><td>asSourceString</td></tr> + * <tr><td>getThenBranch</td><td>getThenExpression</td></tr> + * <tr><td>getType</td><td>getExpressionType</td></tr> + * <tr><td>getTypeParameters</td><td>getTypeArguments</td></tr> + * <tr><td>resolveMethod</td><td>resolve</td></tr> </table> + * + * ### Handlers versus visitors + * If you are processing a method on your own, or even a full class, you should switch from + * JavaRecursiveElementVisitor to AbstractUastVisitor. However, most lint checks don't do their own full AST + * traversal; they instead participate in a shared traversal of the tree, registering element types they're + * interested with using [getApplicableUastTypes] and then providing a visitor where they implement the + * corresponding visit methods. However, from these visitors you should **not** be calling super.visitX. To remove + * this whole confusion, lint now provides a separate class, [UElementHandler]. For the shared traversal, just + * provide this handler instead and implement the appropriate visit methods. It will throw an error if you register + * element types in {@linkplain #getApplicableUastTypes()} that you don't override. + * + * ### Migrating JavaScanner to SourceCodeScanner + * First read the javadoc on how to convert from the older {@linkplain JavaScanner} interface over to {@linkplain + * JavaPsiScanner}. While {@linkplain JavaPsiScanner} is itself deprecated, it's a lot closer to [SourceCodeScanner] + * so a lot of the same concepts apply; then follow the above section. + */ + """ + .trimIndent(), + // {@link} tags are not rendered from [references] when Dokka cannot resolve the symbols + verifyDokka = false) + } + + @Test + fun testPreserveParagraph() { + // Make sure that when we convert <p>, it's preserved. + val source = + """ + /** + * <ul> + * <li>test</li> + * </ul> + * <p> + * After. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(120, 120), + """ + /** + * <ul> + * <li>test</li> + * </ul> + * + * After. + */ + """ + .trimIndent()) + } + + @Test + fun testWordJoining() { + // "-" alone can mean beginning of a list, but not as part of a word + val source = + """ + /** + * which you can render with something like this: + * `dot -Tpng -o/tmp/graph.png toString.dot` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(65), + """ + /** + * which you can render with something like this: `dot -Tpng + * -o/tmp/graph.png toString.dot` + */ + """ + .trimIndent()) + + val source2 = + """ + /** + * ABCDE which you can render with something like this: + * `dot - Tpng -o/tmp/graph.png toString.dot` + */ + """ + .trimIndent() + checkFormatter( + source2, + KDocFormattingOptions(65), + """ + /** + * ABCDE which you can render with something like this: + * `dot - Tpng -o/tmp/graph.png toString.dot` + */ + """ + .trimIndent()) + } + + @Test + fun testEarlyBreakForTodo() { + // Don't break before a TODO + val source = + """ + /** + * This is a long line that will break a little early to breaking at TODO: + * + * This is a long line that wont break a little early to breaking at DODO: + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72).apply { optimal = false }, + """ + /** + * This is a long line that will break a little early to breaking + * at TODO: + * + * This is a long line that wont break a little early to breaking at + * DODO: + */ + """ + .trimIndent()) + } + + @Test + fun testPreformat() { + // Don't join preformatted text with previous TODO comment + val source = + """ + /** + * TODO: Work. + * ``` + * Preformatted. + * + * More preformatted. + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * TODO: Work. + * + * ``` + * Preformatted. + * + * More preformatted. + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testConvertLinks() { + // Make sure we convert {@link} and NOT {@linkplain} if convertMarkup is true. + val source = + """ + /** + * {@link SourceCodeScanner} exposes the UAST API to lint checks. + * The {@link SourceCodeScanner} interface reflects this fact. + * + * {@linkplain SourceCodeScanner} exposes the UAST API to lint checks. + * The {@linkplain SourceCodeScanner} interface reflects this fact. + * + * It will throw an error if you register element types in + * {@link #getApplicableUastTypes()} that you don't override. + * + * First read the javadoc on how to convert from the older {@link + * JavaScanner} interface over to {@link JavaPsiScanner}. + * + * 1. A file header, which is the exact contents of {@link FILE_HEADER} encoded + * as ASCII characters. + * + * Given an error message produced by this lint detector for the + * given issue type, determines whether this corresponds to the + * warning (produced by {@link #reportBaselineIssues(LintDriver, + * Project)} above) that one or more issues have been + * fixed (present in baseline but not in project.) + * + * {@link #getQualifiedName(PsiClass)} method. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * [SourceCodeScanner] exposes the UAST API to lint checks. The + * [SourceCodeScanner] interface reflects this fact. + * + * {@linkplain SourceCodeScanner} exposes the UAST API to lint + * checks. The {@linkplain SourceCodeScanner} interface reflects + * this fact. + * + * It will throw an error if you register element types in + * [getApplicableUastTypes] that you don't override. + * + * First read the javadoc on how to convert from the older + * [JavaScanner] interface over to [JavaPsiScanner]. + * 1. A file header, which is the exact contents of [FILE_HEADER] + * encoded as ASCII characters. + * + * Given an error message produced by this lint detector for the + * given issue type, determines whether this corresponds to the + * warning (produced by [reportBaselineIssues] above) that one or + * more issues have been fixed (present in baseline but not in + * project.) + * + * [getQualifiedName] method. + */ + """ + .trimIndent(), + // When dokka cannot resolve the links it doesn't render {@link} which makes + // before and after not match + verifyDokka = false) + } + + @Test + fun testNestedBullets() { + // Regression test for https://github.com/tnorbye/kdoc-formatter/issues/36 + val source = + """ + /** + * Paragraph + * * Top Bullet + * * Sub-Bullet 1 + * * Sub-Bullet 2 + * * Sub-Sub-Bullet 1 + * 1. Top level + * 1. First item + * 2. Second item + */ + """ + .trimIndent() + + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Paragraph + * * Top Bullet + * * Sub-Bullet 1 + * * Sub-Bullet 2 + * * Sub-Sub-Bullet 1 + * 1. Top level + * 1. First item + * 2. Second item + */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(72, 72).apply { nestedListIndent = 4 }, + """ + /** + * Paragraph + * * Top Bullet + * * Sub-Bullet 1 + * * Sub-Bullet 2 + * * Sub-Sub-Bullet 1 + * 1. Top level + * 1. First item + * 2. Second item + */ + """ + .trimIndent()) + } + + @Test + fun testTripledQuotedPrefixNotBreakable() { + // Corresponds to b/189247595 + val source = + """ + /** + * Gets current ABCD Workspace information from the output of ```abcdtools info```. + * + * Migrated from + * http://com.example + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Gets current ABCD Workspace information from the output + * of ```abcdtools info```. + * + * Migrated from http://com.example + */ + """ + .trimIndent()) + } + + @Test + fun testGreedyLineBreak() { + // Make sure we correctly break at the max line width + val source = + """ + /** + * Handles a chain of qualified expressions, i.e. `a[5].b!!.c()[4].f()` + * + * This is by far the most complicated part of this formatter. We start by breaking the expression + * to the steps it is executed in (which are in the opposite order of how the syntax tree is + * built). + * + * We then calculate information to know which parts need to be groups, and finally go part by + * part, emitting it to the [builder] while closing and opening groups. + * + * @param brokeBeforeBrace used for tracking if a break was taken right before the lambda + * expression. Useful for scoping functions where we want good looking indentation. For example, + * here we have correct indentation before `bar()` and `car()` because we can detect the break + * after the equals: + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 100).apply { optimal = false }, + """ + /** + * Handles a chain of qualified expressions, i.e. `a[5].b!!.c()[4].f()` + * + * This is by far the most complicated part of this formatter. We start by breaking the + * expression to the steps it is executed in (which are in the opposite order of how the syntax + * tree is built). + * + * We then calculate information to know which parts need to be groups, and finally go part by + * part, emitting it to the [builder] while closing and opening groups. + * + * @param brokeBeforeBrace used for tracking if a break was taken right before the lambda + * expression. Useful for scoping functions where we want good looking indentation. For + * example, here we have correct indentation before `bar()` and `car()` because we can detect + * the break after the equals: + */ + """ + .trimIndent()) + } + + @Test + fun test193246766() { + val source = + // Nonsensical text derived from the original using the lorem() method and + // replacing same-length & same capitalization words from lorem ipsum + """ + /** + * * Do do occaecat sunt in culpa: + * * Id id reprehenderit cillum non `adipiscing` enim enim ad occaecat + * * Cupidatat non officia anim adipiscing enim non reprehenderit in officia est: + * * Do non officia anim voluptate esse non mollit mollit id tempor, enim u consequat. irure + * in occaecat + * * Cupidatat, in qui officia anim voluptate esse eu fugiat fugiat in mollit, anim anim id + * occaecat + * * In h anim id laborum: + * * Do non sunt voluptate esse non culpa mollit id tempor, enim u consequat. irure in occaecat + * * Cupidatat, in qui anim voluptate esse non culpa mollit est do tempor, enim enim ad occaecat + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * * Do do occaecat sunt in culpa: + * * Id id reprehenderit cillum non `adipiscing` enim enim ad + * occaecat + * * Cupidatat non officia anim adipiscing enim non reprehenderit + * in officia est: + * * Do non officia anim voluptate esse non mollit mollit id + * tempor, enim u consequat. irure in occaecat + * * Cupidatat, in qui officia anim voluptate esse eu fugiat + * fugiat in mollit, anim anim id occaecat + * * In h anim id laborum: + * * Do non sunt voluptate esse non culpa mollit id tempor, enim + * u consequat. irure in occaecat + * * Cupidatat, in qui anim voluptate esse non culpa mollit est + * do tempor, enim enim ad occaecat + */ + """ + .trimIndent(), + // We indent the last bullets as if they are nested list items; this + // is likely the intent (though with indent only being 2, dokka would + // interpret it as top level text.) + verifyDokka = false) + } + + @Test + fun test203584301() { + // https://github.com/facebookincubator/ktfmt/issues/310 + val source = + """ + /** + * This is my SampleInterface interface. + * @sample com.example.java.sample.library.extra.long.path.MyCustomSampleInterfaceImplementationForTesting + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * This is my SampleInterface interface. + * + * @sample + * com.example.java.sample.library.extra.long.path.MyCustomSampleInterfaceImplementationForTesting + */ + """ + .trimIndent()) + } + + @Test + fun test209435082() { + // b/209435082 + val source = + // Nonsensical text derived from the original using the lorem() method and + // replacing same-length & same capitalization words from lorem ipsum + """ + /** + * eiusmod.com + * - - - + * PARIATUR_MOLLIT + * - - - + * Laborum: 1.4 + * - - - + * Pariatur: + * https://officia.officia.com + * https://id.laborum.laborum.com + * https://sit.eiusmod.com + * https://non-in.officia.com + * https://anim.laborum.com + * https://exercitation.ullamco.com + * - - - + * Adipiscing do tempor: + * - NON: IN/IN + * - in 2IN officia? EST + * - do EIUSMOD eiusmod? NON + * - Mollit est do incididunt Nostrud non? IN + * - Mollit pariatur pariatur culpa? QUI + * - - - + * Lorem eiusmod magna/adipiscing: + * - Do eiusmod magna/adipiscing + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * eiusmod.com + * - - - + * PARIATUR_MOLLIT + * - - - + * Laborum: 1.4 + * - - - + * Pariatur: https://officia.officia.com + * https://id.laborum.laborum.com https://sit.eiusmod.com + * https://non-in.officia.com https://anim.laborum.com + * https://exercitation.ullamco.com + * - - - + * Adipiscing do tempor: + * - NON: IN/IN + * - in 2IN officia? EST + * - do EIUSMOD eiusmod? NON + * - Mollit est do incididunt Nostrud non? IN + * - Mollit pariatur pariatur culpa? QUI + * - - - + * Lorem eiusmod magna/adipiscing: + * - Do eiusmod magna/adipiscing + */ + """ + .trimIndent()) + } + + @Test + fun test236743270() { + val source = + // Nonsensical text derived from the original using the lorem() method and + // replacing same-length & same capitalization words from lorem ipsum + """ + /** + * @return Amet do non adipiscing sed consequat duis non Officia ID (amet sed consequat non + * adipiscing sed eiusmod), magna consequat. + */ + """ + .trimIndent() + val lorem = loremize(source) + assertThat(lorem).isEqualTo(source) + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * @return Amet do non adipiscing sed consequat duis non Officia ID + * (amet sed consequat non adipiscing sed eiusmod), magna + * consequat. + */ + """ + .trimIndent()) + } + + @Test + fun test238279769() { + val source = + // Nonsensical text derived from the original using the lorem() method and + // replacing same-length & same capitalization words from lorem ipsum + """ + /** + * @property dataItemOrderRandomizer sit tempor enim pariatur non culpa id [Pariatur]z in qui anim. + * Anim id-lorem sit magna [Consectetur] pariatur. + * @property randomBytesProvider non mollit anim pariatur non culpa qui qui `mollit` lorem amet + * consectetur [Pariatur]z in IssuerSignedItem culpa. + * @property preserveMapOrder officia id pariatur non culpa id lorem pariatur culpa culpa id o est + * amet consectetur sed sed do ENIM minim. + * @property reprehenderit p esse cillum officia est do enim enim nostrud nisi d non sunt mollit id + * est tempor enim. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * @property dataItemOrderRandomizer sit tempor enim pariatur non + * culpa id [Pariatur]z in qui anim. Anim id-lorem sit magna + * [Consectetur] pariatur. + * @property randomBytesProvider non mollit anim pariatur non culpa + * qui qui `mollit` lorem amet consectetur [Pariatur]z in + * IssuerSignedItem culpa. + * @property preserveMapOrder officia id pariatur non culpa id lorem + * pariatur culpa culpa id o est amet consectetur sed sed do ENIM + * minim. + * @property reprehenderit p esse cillum officia est do enim enim + * nostrud nisi d non sunt mollit id est tempor enim. + */ + """ + .trimIndent()) + } + + @Test + fun testKnit() { + // Some tests for the knit plugin -- https://github.com/Kotlin/kotlinx-knit + val source = + """ + /** + * <!--- <directive> [<parameters>] --> + * <!--- <directive> [<parameters>] + * Some text here. + * This should all be merged into one + * line. + * --> + * <!--- super long text here; this not be broken into lines; super long text here super long text here super long text here super long text here --> + * + * <!--- INCLUDE + * import kotlin.system.* + * --> + * ```kotlin + * fun exit(): Nothing = exitProcess(0) + * ``` + * <!--- PREFIX --> + * <!--- TEST_NAME BasicTest --> + * <!--- TEST + * Hello, world! + * --> + * <!--- TEST lines.single().toInt() in 1..100 --> + * <!--- TOC --> + * <!--- END --> + * <!--- MODULE kotlinx-knit-test --> + * <!--- INDEX kotlinx.knit.test --> + * [captureOutput]: https://example.com/kotlinx-knit-test/kotlinx.knit.test/capture-output.html + * <!--- END --> + * + * Make sure we never line break <!--- to the beginning a line: <!--- <!--- <!--- end. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * <!--- <directive> [<parameters>] --> + * <!--- <directive> [<parameters>] + * Some text here. This should all be merged into one line. + * --> + * <!--- super long text here; this not be broken into lines; super long text here super long text here super long text here super long text here --> + * <!--- INCLUDE + * import kotlin.system.* + * --> + * ```kotlin + * fun exit(): Nothing = exitProcess(0) + * ``` + * <!--- PREFIX --> + * <!--- TEST_NAME BasicTest --> + * <!--- TEST + * Hello, world! + * --> + * <!--- TEST lines.single().toInt() in 1..100 --> + * <!--- TOC --> + * <!--- END --> + * <!--- MODULE kotlinx-knit-test --> + * <!--- INDEX kotlinx.knit.test --> + * [captureOutput]: + * https://example.com/kotlinx-knit-test/kotlinx.knit.test/capture-output.html + * <!--- END --> + * + * Make sure we never line break <!--- to the beginning a + * line: <!--- <!--- <!--- end. + */ + """ + .trimIndent()) + } + + @Test + fun testNPE() { + // Reproduces formatting bug found in androidx' SplashScreen.kt: + val source = + """ + /** + * ## Specs + * - With icon background (`Theme.SplashScreen.IconBackground`) + * + Image Size: 240x240 dp + * + Inner Circle diameter: 160 dp + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * ## Specs + * - With icon background (`Theme.SplashScreen.IconBackground`) + * + Image Size: 240x240 dp + * + Inner Circle diameter: 160 dp + */ + """ + .trimIndent()) + } + + @Test + fun testExtraNewlines() { + // Reproduced a bug which was inserting extra newlines in preformatted text + val source = + """ + /** + * Simple helper class useful for creating a message bundle for your module. + * + * It creates a soft reference to an underlying text bundle, which means that it can + * be garbage collected if needed (although it will be reallocated again if you request + * a new message from it). + * + * You might use it like so: + * + * ``` + * # In module 'custom'... + * + * # resources/messages/CustomBundle.properties: + * sample.text.key=This is a sample text value. + * + * # src/messages/CustomBundle.kt: + * private const val BUNDLE_NAME = "messages.CustomBundle" + * object CustomBundle { + * private val bundleRef = MessageBundleReference(BUNDLE_NAME) + * fun message(@PropertyKey(resourceBundle = BUNDLE_NAME) key: String, vararg params: Any) = bundleRef.message(key, *params) + * } + * ``` + * + * That's it! Now you can call `CustomBundle.message("sample.text.key")` to fetch the text value. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Simple helper class useful for creating a message bundle for your + * module. + * + * It creates a soft reference to an underlying text bundle, which + * means that it can be garbage collected if needed (although it + * will be reallocated again if you request a new message from it). + * + * You might use it like so: + * ``` + * # In module 'custom'... + * + * # resources/messages/CustomBundle.properties: + * sample.text.key=This is a sample text value. + * + * # src/messages/CustomBundle.kt: + * private const val BUNDLE_NAME = "messages.CustomBundle" + * object CustomBundle { + * private val bundleRef = MessageBundleReference(BUNDLE_NAME) + * fun message(@PropertyKey(resourceBundle = BUNDLE_NAME) key: String, vararg params: Any) = bundleRef.message(key, *params) + * } + * ``` + * + * That's it! Now you can call + * `CustomBundle.message("sample.text.key")` + * to fetch the text value. + */ + """ + .trimIndent()) + } + + @Test + fun testQuotedBug() { + // Reproduced a bug which was mishandling quoted strings: when you have + // *separate* but adjacent quoted lists, make sure we preserve line break + // between them + val source = + """ + /** + * Eg: + * > anydpi-v26   |   Adaptive icon - ic_launcher.xml + * + * + * > hdpi      |   Mip Map File - ic_launcher.png + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 72), + """ + /** + * Eg: + * > anydpi-v26   |   Adaptive icon - ic_launcher.xml + * + * > hdpi      |   Mip Map File - + * > ic_launcher.png + */ + """ + .trimIndent(), + indent = " ") + } + + @Test + fun testListBreaking() { + // If we have, in a list, "* very-long-word", we cannot break this line + // with a bullet on its line by itself. In the below, prior to the bug fix, + // the "- spec:width..." would get split into "-" and "spec:width..." on + // its own hanging indent line. + val source = + """ + /** + * In other words, completes the parameters so that either of these declarations can be achieved: + * - spec:width=...,height=...,dpi=...,isRound=...,chinSize=...,orientation=... + * - spec:parent=...,orientation=... + * > spec:width=...,height=...,dpi=...,isRound=...,chinSize=...,orientation=... + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 72), + """ + /** + * In other words, completes the parameters so that either of these + * declarations can be achieved: + * - spec:width=...,height=...,dpi=...,isRound=...,chinSize=...,orientation=... + * - spec:parent=...,orientation=... + * > spec:width=...,height=...,dpi=...,isRound=...,chinSize=...,orientation=... + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testNewList() { + // Make sure we never place "1)" or "+" at the beginning of a new line. + val source = + """ + /** + * Handles both the START_ALLOC_TRACKING and STOP_ALLOC_TRACKING commands in tests. This is responsible for generating a status event. + * For the start tracking command, if |trackStatus| is set to be |SUCCESS|, this generates a start event with timestamp matching what is + * specified in |trackStatus|. For the end tracking command, an event (start timestamp + 1) is only added if a start event already + * exists in the input event list. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100, 72), + """ + /** + * Handles both the START_ALLOC_TRACKING and STOP_ALLOC_TRACKING commands + * in tests. This is responsible for generating a status event. For the + * start tracking command, if |trackStatus| is set to be |SUCCESS|, this + * generates a start event with timestamp matching what is specified + * in |trackStatus|. For the end tracking command, an event (start + * timestamp + 1) is only added if a start event already exists in the + * input event list. + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testSplashScreen() { + val source = + """ + /** + * Provides control over the splash screen once the application is started. + * + * On API 31+ (Android 12+) this class calls the platform methods. + * + * Prior API 31, the platform behavior is replicated with the exception of the Animated Vector + * Drawable support on the launch screen. + * + * # Usage of the `core-splashscreen` library: + * + * To replicate the splash screen behavior from Android 12 on older APIs the following steps need to + * be taken: + * 1. Create a new Theme (e.g `Theme.App.Starting`) and set its parent to `Theme.SplashScreen` or + * `Theme.SplashScreen.IconBackground` + * + * 2. In your manifest, set the `theme` attribute of the whole `<application>` or just the + * starting `<activity>` to `Theme.App.Starting` + * + * 3. In the `onCreate` method the starting activity, call [installSplashScreen] just before + * `super.onCreate()`. You also need to make sure that `postSplashScreenTheme` is set + * to the application's theme. Alternatively, this call can be replaced by [Activity#setTheme] + * if a [SplashScreen] instance isn't needed. + * + * ## Themes + * + * The library provides two themes: [R.style.Theme_SplashScreen] and + * [R.style.Theme_SplashScreen_IconBackground]. If you wish to display a background right under + * your icon, the later needs to be used. This ensure that the scale and masking of the icon are + * similar to the Android 12 Splash Screen. + * + * `windowSplashScreenAnimatedIcon`: The splash screen icon. On API 31+ it can be an animated + * vector drawable. + * + * `windowSplashScreenAnimationDuration`: Duration of the Animated Icon Animation. The value + * needs to be > 0 if the icon is animated. + * + * **Note:** This has no impact on the time during which the splash screen is displayed and is + * only used in [SplashScreenViewProvider.iconAnimationDurationMillis]. If you need to display the + * splash screen for a longer time, you can use [SplashScreen.setKeepOnScreenCondition] + * + * `windowSplashScreenIconBackgroundColor`: _To be used in with + * `Theme.SplashScreen.IconBackground`_. Sets a background color under the splash screen icon. + * + * `windowSplashScreenBackground`: Background color of the splash screen. Defaults to the theme's + * `?attr/colorBackground`. + * + * `postSplashScreenTheme`* Theme to apply to the Activity when [installSplashScreen] is called. + * + * **Known incompatibilities:** + * - On API < 31, `windowSplashScreenAnimatedIcon` cannot be animated. If you want to provide an + * animated icon for API 31+ and a still icon for API <31, you can do so by overriding the still + * icon with an animated vector drawable in `res/drawable-v31`. + * + * - On API < 31, if the value of `windowSplashScreenAnimatedIcon` is an + * [adaptive icon](http://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + * , it will be cropped and scaled. The workaround is to respectively assign + * `windowSplashScreenAnimatedIcon` and `windowSplashScreenIconBackgroundColor` to the values of + * the adaptive icon `foreground` and `background`. + * + * - On API 21-22, The icon isn't displayed until the application starts, only the background is + * visible. + * + * # Design + * The splash screen icon uses the same specifications as + * [Adaptive Icons](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + * . This means that the icon needs to fit within a circle whose diameter is 2/3 the size of the + * icon. The actual values don't really matter if you use a vector icon. + * + * ## Specs + * - With icon background (`Theme.SplashScreen.IconBackground`) + * + Image Size: 240x240 dp + * + Inner Circle diameter: 160 dp + * - Without icon background (`Theme.SplashScreen`) + * + Image size: 288x288 dp + * + Inner circle diameter: 192 dp + * + * _Example:_ if the full size of the image is 300dp*300dp, the icon needs to fit within a + * circle with a diameter of 200dp. Everything outside the circle will be invisible (masked). + * + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Provides control over the splash screen once the application is + * started. + * + * On API 31+ (Android 12+) this class calls the platform methods. + * + * Prior API 31, the platform behavior is replicated with the + * exception of the Animated Vector Drawable support on the launch + * screen. + * + * # Usage of the `core-splashscreen` library: + * + * To replicate the splash screen behavior from Android 12 on older + * APIs the following steps need to be taken: + * 1. Create a new Theme (e.g `Theme.App.Starting`) and set its + * parent to `Theme.SplashScreen` or + * `Theme.SplashScreen.IconBackground` + * 2. In your manifest, set the `theme` attribute of the whole + * `<application>` or just the starting `<activity>` to + * `Theme.App.Starting` + * 3. In the `onCreate` method the starting activity, call + * [installSplashScreen] just before `super.onCreate()`. You also + * need to make sure that `postSplashScreenTheme` is set to the + * application's theme. Alternatively, this call can be replaced + * by [Activity#setTheme] if a [SplashScreen] instance isn't + * needed. + * + * ## Themes + * + * The library provides two themes: [R.style.Theme_SplashScreen] + * and [R.style.Theme_SplashScreen_IconBackground]. If you wish to + * display a background right under your icon, the later needs to + * be used. This ensure that the scale and masking of the icon are + * similar to the Android 12 Splash Screen. + * + * `windowSplashScreenAnimatedIcon`: The splash screen icon. On API + * 31+ it can be an animated vector drawable. + * + * `windowSplashScreenAnimationDuration`: Duration of the Animated + * Icon Animation. The value needs to be > 0 if the icon is + * animated. + * + * **Note:** This has no impact on the time during which + * the splash screen is displayed and is only used in + * [SplashScreenViewProvider.iconAnimationDurationMillis]. If you + * need to display the splash screen for a longer time, you can use + * [SplashScreen.setKeepOnScreenCondition] + * + * `windowSplashScreenIconBackgroundColor`: _To be used in with + * `Theme.SplashScreen.IconBackground`_. Sets a background color + * under the splash screen icon. + * + * `windowSplashScreenBackground`: Background color of the splash + * screen. Defaults to the theme's `?attr/colorBackground`. + * + * `postSplashScreenTheme`* Theme to apply to the Activity when + * [installSplashScreen] is called. + * + * **Known incompatibilities:** + * - On API < 31, `windowSplashScreenAnimatedIcon` cannot be + * animated. If you want to provide an animated icon for API 31+ + * and a still icon for API <31, you can do so by overriding the + * still icon with an animated vector drawable in + * `res/drawable-v31`. + * - On API < 31, if the value of `windowSplashScreenAnimatedIcon` + * is an + * [adaptive icon](http://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + * , it will be cropped and scaled. The workaround is to + * respectively assign `windowSplashScreenAnimatedIcon` and + * `windowSplashScreenIconBackgroundColor` to the values of the + * adaptive icon `foreground` and `background`. + * - On API 21-22, The icon isn't displayed until the application + * starts, only the background is visible. + * + * # Design + * The splash screen icon uses the same specifications as + * [Adaptive Icons](https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive) + * . This means that the icon needs to fit within a circle + * whose diameter is 2/3 the size of the icon. The actual + * values don't really matter if you use a vector icon. + * + * ## Specs + * - With icon background (`Theme.SplashScreen.IconBackground`) + * + Image Size: 240x240 dp + * + Inner Circle diameter: 160 dp + * - Without icon background (`Theme.SplashScreen`) + * + Image size: 288x288 dp + * + Inner circle diameter: 192 dp + * + * _Example:_ if the full size of the image is 300dp*300dp, the icon + * needs to fit within a circle with a diameter of 200dp. Everything + * outside the circle will be invisible (masked). + */ + """ + .trimIndent()) + } + + @Test + fun testRaggedIndentation() { + // From Dokka's plugins/base/src/main/kotlin/translators/psi/parsers/JavadocParser.kt + val source = + """ + /** + * We would like to know if we need to have a space after a this tag + * + * The space is required when: + * - tag spans multiple lines, between every line we would need a space + * + * We wouldn't like to render a space if: + * - tag is followed by an end of comment + * - after a tag there is another tag (eg. multiple @author tags) + * - they end with an html tag like: <a href="...">Something</a> since then the space will be displayed in the following text + * - next line starts with a <p> or <pre> token + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * We would like to know if we need to have a space after a this tag + * + * The space is required when: + * - tag spans multiple lines, between every line we would need a + * space + * + * We wouldn't like to render a space if: + * - tag is followed by an end of comment + * - after a tag there is another tag (eg. multiple @author tags) + * - they end with an html tag like: <a href="...">Something</a> + * since then the space will be displayed in the following text + * - next line starts with a <p> or <pre> token + */ + """ + .trimIndent()) + } + + @Test + fun testCustomKDocTag() { + // From Dokka's core/testdata/comments/multilineSection.kt + val source = + """ + /** + * Summary + * @one + * line one + * line two + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72, 72), + """ + /** + * Summary + * + * @one line one line two + */ + """ + .trimIndent()) + } + + @Test + fun testTables() { + val source = + """ + /** + * ### Tables + * column 1 | column 2 + * ---------|--------- + * value\| 1 | value 2 + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * ### Tables + * | column 1 | column 2 | + * |-----------|----------| + * | value\| 1 | value 2 | + */ + """ + .trimIndent()) + } + + @Test + fun testTableMixedWithHtml() { + // https://stackoverflow.com/questions/19950648/how-to-write-lists-inside-a-markdown-table + val source = + """ + /** + | Tables | Are | Cool | + | ------------- |:-------------:| -----:| + | col 3 is | right-aligned | 1600 | + | col 2 is | centered | 12 | + | zebra stripes | are neat | 1 | + | <ul><li>item1</li><li>item2</li></ul>| See the list | from the first column| + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100), + """ + /** + * | Tables | Are | Cool | + * |---------------------------------------|:-------------:|----------------------:| + * | col 3 is | right-aligned | 1600 | + * | col 2 is | centered | 12 | + * | zebra stripes | are neat | 1 | + * | <ul><li>item1</li><li>item2</li></ul> | See the list | from the first column | + */ + """ + .trimIndent()) + + // Reduce formatting width to 40; table won't fit, but we'll skip the padding + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * |Tables |Are |Cool | + * |-------------------------------------|:-----------:|--------------------:| + * |col 3 is |right-aligned| 1600| + * |col 2 is | centered | 12| + * |zebra stripes | are neat | 1| + * |<ul><li>item1</li><li>item2</li></ul>|See the list |from the first column| + */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(40).apply { alignTableColumns = false }, + """ + /** + * | Tables | Are | Cool | + * | ------------- |:-------------:| -----:| + * | col 3 is | right-aligned | 1600 | + * | col 2 is | centered | 12 | + * | zebra stripes | are neat | 1 | + * | <ul><li>item1</li><li>item2</li></ul>| See the list | from the first column| + */ + """ + .trimIndent()) + } + + @Test + fun testTableExtraCells() { + // If there are extra columns in a row (after the header and divider), + // preserve these (though Dokka will drop them from the rendering); don't + // widen the table to accommodate it. + val source = + """ + /** + | Tables | Are | Cool | + | ------------- |:-------------:| -----:| + | col 3 is | right-aligned | 1600 | + | col 2 is | centered | 12 | extra + | zebra stripes | are neat | 1 | + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(100), + """ + /** + * | Tables | Are | Cool | + * |---------------|:-------------:|-----:| + * | col 3 is | right-aligned | 1600 | + * | col 2 is | centered | 12 | extra | + * | zebra stripes | are neat | 1 | + */ + """ + .trimIndent()) + } + + @Test + fun testTables2() { + // See https://github.com/Kotlin/dokka/issues/199 + val source = + """ + /** + * | Level | Color | + * | ----- | ----- | + * | ERROR | RED | + * | WARN | YELLOW | + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * | Level | Color | + * |-------|--------| + * | ERROR | RED | + * | WARN | YELLOW | + */ + """ + .trimIndent()) + + // With alignTableColumns=false, leave formatting within table cells alone + checkFormatter( + source, + KDocFormattingOptions(40).apply { alignTableColumns = false }, + """ + /** + * | Level | Color | + * | ----- | ----- | + * | ERROR | RED | + * | WARN | YELLOW | + */ + """ + .trimIndent()) + } + + @Test + fun testTables3() { + val source = + """ + /** + * Line Before + * # test + * |column 1 | column 2 | column3 + * |---|---|--- + * value 1 | value 3 + * this is missing + * this is more + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40).apply { alignTableColumns = true }, + """ + /** + * Line Before + * + * # test + * | column 1 | column 2 | column3 | + * |----------|----------|---------| + * | value 1 | value 3 | | + * + * this is missing this is more + */ + """ + .trimIndent()) + + checkFormatter( + source, + KDocFormattingOptions(40).apply { alignTableColumns = false }, + """ + /** + * Line Before + * + * # test + * |column 1 | column 2 | column3 + * |---|---|--- + * value 1 | value 3 + * + * this is missing this is more + */ + """ + .trimIndent()) + } + + @Test + fun testTables4() { + // Test short dividers (:--, :-:, --:) + val source = + """ + /** + * ### Tables + * column 1 | column 2 | column3 + * :-:|--:|:-- + * cell 1|cell2|cell3 + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /** + * ### Tables + * | column 1 | column 2 | column3 | + * |:--------:|---------:|---------| + * | cell 1 | cell2 | cell3 | + */ + """ + .trimIndent(), + // Dokka doesn't actually handle this right; it looks for --- + verifyDokka = false) + } + + @Test + fun testTablesEmptyCells() { + // Checks what happens with blank cells (here in column 0 on the last row). Test case from + // Studio's + // designer/testSrc/com/android/tools/idea/uibuilder/property/testutils/AndroidAttributeTypeLookup.kt + val source = + """ + /** + * | Function | Type | Notes | + * | -------------------------------- | ------------------------------- | --------------------------------------| + * | TypedArray.getDrawable | NlPropertyType.DRAWABLE | | + * | TypedArray.getColor | NlPropertyType.COLOR | Make sure this is not a color list !! | + * | TypedArray.getColorStateList | NlPropertyType.COLOR_STATE_LIST | | + * | TypedArray.getDimensionPixelSize | NlPropertyType.DIMENSION | | + * | TypedArray.getResourceId | NlPropertyType.ID | | + * | TypedArray.getInt | NlPropertyType.ENUM | If attrs.xml defines this as an enum | + * | | NlPropertyType.INTEGER | If this is not an enum | + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * |Function |Type |Notes | + * |--------------------------------|-------------------------------|-------------------------------------| + * |TypedArray.getDrawable |NlPropertyType.DRAWABLE | | + * |TypedArray.getColor |NlPropertyType.COLOR |Make sure this is not a color list !!| + * |TypedArray.getColorStateList |NlPropertyType.COLOR_STATE_LIST| | + * |TypedArray.getDimensionPixelSize|NlPropertyType.DIMENSION | | + * |TypedArray.getResourceId |NlPropertyType.ID | | + * |TypedArray.getInt |NlPropertyType.ENUM |If attrs.xml defines this as an enum | + * | |NlPropertyType.INTEGER |If this is not an enum | + */ + """ + .trimIndent()) + } + + @Test + fun testTables5() { + // Test case from Studio's + // project-system-gradle-upgrade/src/com/android/tools/idea/gradle/project/upgrade/AgpUpgradeRefactoringProcessor.kt + val source = + """ + /** + | 1 | 2 | 3 | 4 | Necessity + |---|---|---|---|---------- + |v_n|v_o|cur|new| [IRRELEVANT_PAST] + |cur|new|v_n|v_o| [IRRELEVANT_FUTURE] + |cur|v_n|v_o|new| [MANDATORY_CODEPENDENT] (must do the refactoring in the same action as the AGP version upgrade) + |v_n|cur|v_o|new| [MANDATORY_INDEPENDENT] (must do the refactoring, but can do it before the AGP version upgrade) + |cur|v_n|new|v_o| [OPTIONAL_CODEPENDENT] (need not do the refactoring, but if done must be with or after the AGP version upgrade) + |v_n|cur|new|v_o| [OPTIONAL_INDEPENDENT] (need not do the refactoring, but if done can be at any point in the process) + + For the possibly-simpler case where we have a discontinuity in behaviour, v_o = v_n = vvv, and the three possible cases are: + + | 1 | 2 | 3 | Necessity + +---+---+---+---------- + |vvv|cur|new| [IRRELEVANT_PAST] + |cur|vvv|new| [MANDATORY_CODEPENDENT] + |cur|new|vvv| [IRRELEVANT_FUTURE] + + (again in case of equality, vvv sorts before cur and new) + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * |1 |2 |3 |4 |Necessity | + * |---|---|---|---|---------------------------------------------------------------------------------------------------------------| + * |v_n|v_o|cur|new|[IRRELEVANT_PAST] | + * |cur|new|v_n|v_o|[IRRELEVANT_FUTURE] | + * |cur|v_n|v_o|new|[MANDATORY_CODEPENDENT] (must do the refactoring in the same action as the AGP version upgrade) | + * |v_n|cur|v_o|new|[MANDATORY_INDEPENDENT] (must do the refactoring, but can do it before the AGP version upgrade) | + * |cur|v_n|new|v_o|[OPTIONAL_CODEPENDENT] (need not do the refactoring, but if done must be with or after the AGP version upgrade)| + * |v_n|cur|new|v_o|[OPTIONAL_INDEPENDENT] (need not do the refactoring, but if done can be at any point in the process) | + * + * For the possibly-simpler case where we have a discontinuity in + * behaviour, v_o = v_n = vvv, and the three possible cases are: + * + * | 1 | 2 | 3 | Necessity +---+---+---+---------- |vvv|cur|new| + * [IRRELEVANT_PAST] |cur|vvv|new| [MANDATORY_CODEPENDENT] |cur|new|vvv| + * [IRRELEVANT_FUTURE] + * + * (again in case of equality, vvv sorts before cur and new) + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testTables6() { + // Test case from IntelliJ's + // plugins/kotlin/idea/tests/testData/editor/quickDoc/OnFunctionDeclarationWithGFMTable.kt + val source = + """ + /** + * | left | center | right | default | + * | :---- | :----: | ----: | ------- | + * | 1 | 2 | 3 | 4 | + * + * + * | foo | bar | baz | + * | --- | --- | --- | + * | 1 | 2 | + * | 3 | 4 | 5 | 6 | + * + * | header | only | + * | ------ | ---- | + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * | left | center | right | default | + * |------|:------:|------:|---------| + * | 1 | 2 | 3 | 4 | + * + * | foo | bar | baz | + * |-----|-----|-----| + * | 1 | 2 | | + * | 3 | 4 | 5 | 6 | + * + * | header | only | + * |--------|------| + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testTables7() { + val source = + """ + /** + * This is my code + * @author Me + * And here's. + * Another. + * Thing. + * + * my | table + * ---|--- + * item 1|item 2 + * item 3| + * item 4|item 5 + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * This is my code + * + * @author Me And here's. Another. Thing. + * + * | my | table | + * |--------|--------| + * | item 1 | item 2 | + * | item 3 | | + * | item 4 | item 5 | + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testTables7b() { + val source = + """ + /** + * This is my code + * @author Me + * Plain text. + * + * my | table + * ---|--- + * item 1|item 2 + * item 3| + * item 4|item 5 + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72).apply { + orderDocTags = false + alignTableColumns = false + }, + """ + /** + * This is my code + * + * @author Me Plain text. + * + * my | table + * ---|--- + * item 1|item 2 + * item 3| + * item 4|item 5 + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testBulletsUnderParamTags() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/56 + val source = + """ + /** + * This supports bullets + * - one + * - two + * + * @param thisDoesNot + * Here's some parameter text. + * - a + * - b + * Here's some more text + * + * And here's even more parameter doc text. + * + * @param another paragraph + * * With some bulleted items + * * Even nested ones + * ``` + * and some preformatted text + * ``` + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72).apply { orderDocTags = false }, + """ + /** + * This supports bullets + * - one + * - two + * + * @param thisDoesNot Here's some parameter text. + * - a + * - b Here's some more text + * + * And here's even more parameter doc text. + * + * @param another paragraph + * * With some bulleted items + * * Even nested ones + * + * ``` + * and some preformatted text + * ``` + */ + """ + .trimIndent()) + } + + @Test + fun testLineBreaking() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/57 + val source = + """ + /** aa aa aa aa a */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 20, maxCommentWidth = 20).apply { optimal = false }, + """ + /** aa aa aa aa a */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testPreTag() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/58 + val source = + """ + /** + * This tag messes things up. + * <pre> + * This is pre. + * + * @return some correct + * value + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * This tag messes things up. + * <pre> + * This is pre. + * + * @return some correct + * value + */ + """ + .trimIndent(), + verifyDokka = false // this triggers a bug in the diff lookup; TODO investigate + ) + } + + @Test + fun testPreTag2() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/58 + val source = + """ + /** + * Even if it's closed. + * <pre>My Pre</pre> + * + * @return some correct + * value + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Even if it's closed. + * + * ``` + * My Pre + * ``` + * + * @return some correct value + */ + """ + .trimIndent(), + // <pre> and ``` are rendered differently; this is an intentional diff + verifyDokka = false) + } + + @Test + fun testPreTag3() { + // From Studio's + // build-system/builder-model/src/main/java/com/android/builder/model/DataBindingOptions.kt + val source = + """ + /** + * Whether we want tests to be able to use data binding as well. + * + * <p> + * Data Binding classes generated from the application can always be + * accessed in the test code but test itself cannot introduce new + * Data Binding layouts, bindables etc unless this flag is turned + * on. + * + * <p> + * This settings help with an issue in older devices where class + * verifier throws an exception when the application class is + * overwritten by the test class. It also makes it easier to run + * proguarded tests. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Whether we want tests to be able to use data binding as well. + * + * Data Binding classes generated from the application can always be + * accessed in the test code but test itself cannot introduce new + * Data Binding layouts, bindables etc unless this flag is turned + * on. + * + * This settings help with an issue in older devices where class + * verifier throws an exception when the application class is + * overwritten by the test class. It also makes it easier to run + * proguarded tests. + */ + """ + .trimIndent()) + } + + @Test + fun testNoConversionInReferences() { + val source = + """ + /** + * A thread safe in-memory cache of [Key<T>][Key] to `T` values whose lifetime is tied + * to a [CoroutineScope]. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * A thread safe in-memory cache of [Key<T>][Key] to `T` values + * whose lifetime is tied to a [CoroutineScope]. + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testCaseSensitiveMarkup() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/59 + val source = + """ + /** <A> to <B> should remain intact, not <b>bolded</b> */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** <A> to <B> should remain intact, not **bolded** */ + """ + .trimIndent(), + // This is a broken comment (unterminated <B> etc) so the behaviors differ + verifyDokka = false) + } + + @Test + fun testAsteriskRemoval() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/60 + val source = + """ + /** *** Testing */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** *** Testing */ + """ + .trimIndent()) + } + + @Test + fun testParagraphTagRemoval() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/61 + val source = + """ + /** + * Ptag removal should remove extra space + * + * <p> Some paragraph + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Ptag removal should remove extra space + * + * Some paragraph + */ + """ + .trimIndent()) + } + + @Test + fun testDashedLineIndentation() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/62 + val source = + """ + /** + * Some summary. + * + * - Some bullet. + * + * ------------------------------------------------------------------------------ + * + * Some paragraph. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Some summary. + * - Some bullet. + * + * ------------------------------------------------------------------------------ + * + * Some paragraph. + */ + """ + .trimIndent()) + } + + @Test + fun testParagraphRemoval() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/63 + val source = + """ + /** + * 1. Test + * + * <p>2. Test + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * 1. Test + * 2. Test + */ + """ + .trimIndent(), + // We deliberately allow list items to jump up across blank lines + verifyDokka = false) + } + + @Test + fun testParagraphRemoval2() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/69 + val source = + """ + /** + * Some title + * + * <p>1. Test + * 2. Test + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Some title + * 1. Test + * 2. Test + */ + """ + .trimIndent(), + // We deliberately allow list items to jump up across blank lines + verifyDokka = false) + } + + @Test + fun testAtBreak2() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/64 + // This behavior is deliberate: we cannot put @aa at the beginning of a new line; + // if so KDoc will treat it as a doc and silently drop it because it isn't a known + // custom tag. + val source = + """ + /** + * aa aa aa aa aa @aa + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 20, maxCommentWidth = 20), + """ + /** + * aa aa aa aa + * aa @aa + */ + """ + .trimIndent()) + } + + @Test + fun testNoBreakAfterAt() { + // Regression test for + // https://github.com/tnorbye/kdoc-formatter/issues/65 + val source = + """ + /** + * Weird break + * + * alink aaaaaaa + * + * @param a aaaaaa + * @link aaaaaaa + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 20, maxCommentWidth = 20), + """ + /** + * Weird break + * + * alink aaaaaaa + * + * @param a aaaaaa + * @link aaaaaaa + */ + """ + .trimIndent(), + indent = "") + } + + @Test + fun testPreCodeConversion() { + val source = + """ + /** + * <pre><code> + * More sample code. + * </code></pre> + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * ``` + * More sample code. + * ``` + */ + """ + .trimIndent(), + indent = " ", + // <pre> and ``` are rendered differently; this is an intentional diff + verifyDokka = false) + } + + @Test + fun testPreConversion2() { + // From AndroidX and Studio methods + val source = + """ + /** + * Checks if any of the GL calls since the last time this method was called set an error + * condition. Call this method immediately after calling a GL method. Pass the name of the GL + * operation. For example: + * + * <pre> + * mColorHandle = GLES20.glGetUniformLocation(mProgram, "uColor"); + * MyGLRenderer.checkGlError("glGetUniformLocation");</pre> + * + * If the operation is not successful, the check throws an exception. + * + * <pre>public performItemClick(T item) { + * ... + * sendEventForVirtualView(item.id, AccessibilityEvent.TYPE_VIEW_CLICKED) + * } + * </pre> + * *Note* This is quite slow so it's best to use it sparingly in production builds. + * Injector to load associated file. It will create code like: + * <pre>file = FileUtil.loadLabels(extractor.getAssociatedFile(fileName))</pre> + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(maxLineWidth = 72), + """ + /** + * Checks if any of the GL calls since the last time this + * method was called set an error condition. Call this method + * immediately after calling a GL method. Pass the name of the + * GL operation. For example: + * ``` + * mColorHandle = GLES20.glGetUniformLocation(mProgram, "uColor"); + * MyGLRenderer.checkGlError("glGetUniformLocation"); + * ``` + * + * If the operation is not successful, the check throws an + * exception. + * + * ``` + * public performItemClick(T item) { + * ... + * sendEventForVirtualView(item.id, AccessibilityEvent.TYPE_VIEW_CLICKED) + * } + * ``` + * + * *Note* This is quite slow so it's best to use it sparingly in + * production builds. Injector to load associated file. It will + * create code like: + * ``` + * file = FileUtil.loadLabels(extractor.getAssociatedFile(fileName)) + * ``` + */ + """ + .trimIndent(), + indent = " ", + // <pre> and ``` are rendered differently; this is an intentional diff + verifyDokka = false) + } + + /** + * Test utility method: from a source kdoc, derive an "equivalent" kdoc (same punctuation, + * whitespace, capitalization and length of words) with words from Lorem Ipsum. Useful to create + * test cases for the formatter without checking in original comments. + */ + private fun loremize(s: String): String { + val lorem = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " + + "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco " + + "laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in " + + "voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat " + + "non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" + val loremWords = lorem.filter { it.isLetter() || it == ' ' }.lowercase().split(" ") + var next = 0 + + fun adjustCapitalization(word: String, original: String): String { + return if (original[0].isUpperCase()) { + if (original.all { it.isUpperCase() }) { + word.uppercase() + } else { + word.replaceFirstChar { it.uppercase() } + } + } else { + word + } + } + + fun nextLorem(word: String): String { + val length = word.length + val start = next + while (next < loremWords.size) { + val nextLorem = loremWords[next] + if (nextLorem.length == length) { + return adjustCapitalization(nextLorem, word) + } + next++ + } + next = 0 + while (next < start) { + val nextLorem = loremWords[next] + if (nextLorem.length == length) { + return adjustCapitalization(nextLorem, word) + } + next++ + } + if (length == 1) { + return ('a' + (start % 26)).toString() + } + // No match for this word + return word + } + + val sb = StringBuilder() + var i = 0 + while (i < s.length) { + val c = s[i] + if (c.isLetter()) { + var end = i + 1 + while (end < s.length && s[end].isLetter()) { + end++ + } + val word = s.substring(i, end) + if (i > 0 && s[i - 1] == '@' || word == "http" || word == "https" || word == "com") { + // Don't translate URL prefix/suffixes and doc tags + sb.append(word) + } else { + sb.append(nextLorem(word)) + } + i = end + } else { + sb.append(c) + i++ + } + } + return sb.toString() + } + + // -------------------------------------------------------------------- + // A few failing test cases here for corner cases that aren't handled + // right yet. + // -------------------------------------------------------------------- + + @Ignore("Lists within quoted blocks not yet supported") + @Test + fun testNestedWithinQuoted() { + val source = + """ + /* + * Lists within a block quote: + * > Here's my quoted text. + * > 1. First item + * > 2. Second item + * > 3. Third item + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40), + """ + /* + * Lists within a block quote: + * > Here's my quoted text. + * > 1. First item + * > 2. Second item + * > 3. Third item + */ + """ + .trimIndent()) + + checkFormatter( + """ + /** + * Here's some text. + * > Here's some more text that + * > is indented. More text. + * > > And here's some even + * > > more indented text + * > Back to the top level + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 60), + """ + /** + * Here's some text. + * > Here's some more text that + * > is indented. More text. + * > > And here's some even + * > > more indented text + * > Back to the top level + */ + """ + .trimIndent()) } } diff --git a/core/src/test/java/com/facebook/ktfmt/kdoc/UtilitiesTest.kt b/core/src/test/java/com/facebook/ktfmt/kdoc/UtilitiesTest.kt new file mode 100644 index 0000000..e5ad1a7 --- /dev/null +++ b/core/src/test/java/com/facebook/ktfmt/kdoc/UtilitiesTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) Tor Norbye. + * + * 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 com.facebook.ktfmt.kdoc + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class UtilitiesTest { + @Test + fun testFindSamePosition() { + fun check(newWithCaret: String, oldWithCaret: String) { + val oldCaretIndex = oldWithCaret.indexOf('|') + val newCaretIndex = newWithCaret.indexOf('|') + assertThat(oldCaretIndex != -1).isTrue() + assertThat(newCaretIndex != -1).isTrue() + val old = oldWithCaret.substring(0, oldCaretIndex) + oldWithCaret.substring(oldCaretIndex + 1) + val new = newWithCaret.substring(0, newCaretIndex) + newWithCaret.substring(newCaretIndex + 1) + val newPos = findSamePosition(old, oldCaretIndex, new) + + val actual = new.substring(0, newPos) + "|" + new.substring(newPos) + assertThat(actual).isEqualTo(newWithCaret) + } + + // Prefix match + check("|/** Test\n Different Middle End */", "|/** Test2 End */") + check("/|** Test\n Different Middle End */", "/|** Test2 End */") + check("/*|* Test\n Different Middle End */", "/*|* Test2 End */") + check("/**| Test\n Different Middle End */", "/**| Test2 End */") + check("/** |Test\n Different Middle End */", "/** |Test2 End */") + check("/** T|est\n Different Middle End */", "/** T|est2 End */") + check("/** Te|st\n Different Middle End */", "/** Te|st2 End */") + check("/** Tes|t\n Different Middle End */", "/** Tes|t2 End */") + check("/** Test|\n Different Middle End */", "/** Test|2 End */") + // End match + check("/** Test\n Different Middle| End */", "/** Test2| End */") + check("/** Test\n Different Middle E|nd */", "/** Test2 E|nd */") + check("/** Test\n Different Middle En|d */", "/** Test2 En|d */") + check("/** Test\n Different Middle End| */", "/** Test2 End| */") + check("/** Test\n Different Middle End |*/", "/** Test2 End |*/") + check("/** Test\n Different Middle End *|/", "/** Test2 End *|/") + check("/** Test\n Different Middle End */|", "/** Test2 End */|") + + check("|/**\nTest End\n*/", "|/** Test End */") + check("/|**\nTest End\n*/", "/|** Test End */") + check("/*|*\nTest End\n*/", "/*|* Test End */") + check("/**|\nTest End\n*/", "/**| Test End */") + check("/**\n|Test End\n*/", "/** |Test End */") + check("/**\nT|est End\n*/", "/** T|est End */") + check("/**\nTe|st End\n*/", "/** Te|st End */") + check("/**\nTes|t End\n*/", "/** Tes|t End */") + check("/**\nTest| End\n*/", "/** Test| End */") + check("/**\nTest |End\n*/", "/** Test |End */") + check("/**\nTest E|nd\n*/", "/** Test E|nd */") + check("/**\nTest En|d\n*/", "/** Test En|d */") + check("/**\nTest End|\n*/", "/** Test End| */") + check("/**\nTest End\n|*/", "/** Test End |*/") + check("/**\nTest End\n*|/", "/** Test End *|/") + check("/**\nTest End\n*/|", "/** Test End */|") + + check("|/** Test End */", "|/** Test2 End */") + check("/|** Test End */", "/|** Test2 End */") + check("/*|* Test End */", "/*|* Test2 End */") + check("/**| Test End */", "/**| Test2 End */") + check("/** |Test End */", "/** |Test2 End */") + check("/** T|est End */", "/** T|est2 End */") + check("/** Te|st End */", "/** Te|st2 End */") + check("/** Tes|t End */", "/** Tes|t2 End */") + check("/** Test| End */", "/** Test|2 End */") + check("/** Test |End */", "/** Test2 |End */") + check("/** Test E|nd */", "/** Test2 E|nd */") + check("/** Test En|d */", "/** Test2 En|d */") + check("/** Test End| */", "/** Test2 End| */") + check("/** Test End |*/", "/** Test2 End |*/") + check("/** Test End *|/", "/** Test2 End *|/") + check("/** Test End */|", "/** Test2 End */|") + } + + @Test + fun testGetParamName() { + assertThat("@param foo".getParamName()).isEqualTo("foo") + assertThat("@param foo bar".getParamName()).isEqualTo("foo") + assertThat("@param foo;".getParamName()).isEqualTo("foo") + assertThat(" \t@param\t foo bar.".getParamName()).isEqualTo("foo") + assertThat("@param[foo]".getParamName()).isEqualTo("foo") + assertThat("@param [foo]".getParamName()).isEqualTo("foo") + assertThat("@param ".getParamName()).isEqualTo(null) + } +} @@ -5,7 +5,7 @@ <groupId>com.facebook</groupId> <artifactId>ktfmt-parent</artifactId> - <version>0.39</version> + <version>0.42</version> <packaging>pom</packaging> <name>Ktfmt Parent</name> diff --git a/version.txt b/version.txt index 751b1ea..f4bb45b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.39 +0.42 diff --git a/website/package-lock.json b/website/package-lock.json index c178ada..d28214b 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -561,7 +561,7 @@ "node_modules/camelcase": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "integrity": "sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -2394,9 +2394,9 @@ } }, "node_modules/json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, "node_modules/json-schema-traverse": { @@ -2418,18 +2418,18 @@ "dev": true }, "node_modules/jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "dev": true, - "engines": [ - "node >=0.6.0" - ], "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", - "json-schema": "0.2.3", + "json-schema": "0.4.0", "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" } }, "node_modules/just-debounce": { @@ -5064,9 +5064,9 @@ "dev": true }, "node_modules/yargs": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.1.tgz", - "integrity": "sha512-huO4Fr1f9PmiJJdll5kwoS2e4GqzGSsMT3PPMpOwoVkOK8ckqAewMTZyA6LXVQWflleb/Z8oPBEvNsMft0XE+g==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.2.tgz", + "integrity": "sha512-ZEjj/dQYQy0Zx0lgLMLR8QuaqTihnxirir7EwUHp1Axq4e3+k8jXU5K0VLbNvedv1f4EWtBonDIZm0NUr+jCcA==", "dev": true, "dependencies": { "camelcase": "^3.0.0", @@ -5081,13 +5081,13 @@ "string-width": "^1.0.2", "which-module": "^1.0.0", "y18n": "^3.2.1", - "yargs-parser": "5.0.0-security.0" + "yargs-parser": "^5.0.1" } }, "node_modules/yargs-parser": { - "version": "5.0.0-security.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0-security.0.tgz", - "integrity": "sha512-T69y4Ps64LNesYxeYGYPvfoMTt/7y1XtfpIslUeK4um+9Hu7hlGoRtaDLvdXb7+/tfq4opVa2HRY5xGip022rQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.1.tgz", + "integrity": "sha512-wpav5XYiddjXxirPoCTUPbqM0PXvJ9hiBMvuJgInvo4/lAOTZzUprArw17q2O1P2+GHhbBr18/iQwjL5Z9BqfA==", "dev": true, "dependencies": { "camelcase": "^3.0.0", @@ -5545,7 +5545,7 @@ "camelcase": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "integrity": "sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==", "dev": true }, "caseless": { @@ -7048,9 +7048,9 @@ } }, "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, "json-schema-traverse": { @@ -7072,14 +7072,14 @@ "dev": true }, "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", - "json-schema": "0.2.3", + "json-schema": "0.4.0", "verror": "1.10.0" } }, @@ -9229,9 +9229,9 @@ "dev": true }, "yargs": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.1.tgz", - "integrity": "sha512-huO4Fr1f9PmiJJdll5kwoS2e4GqzGSsMT3PPMpOwoVkOK8ckqAewMTZyA6LXVQWflleb/Z8oPBEvNsMft0XE+g==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.2.tgz", + "integrity": "sha512-ZEjj/dQYQy0Zx0lgLMLR8QuaqTihnxirir7EwUHp1Axq4e3+k8jXU5K0VLbNvedv1f4EWtBonDIZm0NUr+jCcA==", "dev": true, "requires": { "camelcase": "^3.0.0", @@ -9246,13 +9246,13 @@ "string-width": "^1.0.2", "which-module": "^1.0.0", "y18n": "^3.2.1", - "yargs-parser": "5.0.0-security.0" + "yargs-parser": "^5.0.1" } }, "yargs-parser": { - "version": "5.0.0-security.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0-security.0.tgz", - "integrity": "sha512-T69y4Ps64LNesYxeYGYPvfoMTt/7y1XtfpIslUeK4um+9Hu7hlGoRtaDLvdXb7+/tfq4opVa2HRY5xGip022rQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.1.tgz", + "integrity": "sha512-wpav5XYiddjXxirPoCTUPbqM0PXvJ9hiBMvuJgInvo4/lAOTZzUprArw17q2O1P2+GHhbBr18/iQwjL5Z9BqfA==", "dev": true, "requires": { "camelcase": "^3.0.0", |