From 582c58a7a243a2637a343f112e2dd9c1df8b900e Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Thu, 23 Feb 2023 10:29:01 +0100 Subject: Revert "Revert "Merge tag 'v0.42' into update"" This reverts commit 0c522a74a45613a7831f8abf423e83a38d409886. Test: m ktfmt Bug: 266197805 Change-Id: I87b9b9b9e70acabfd4a26a628cb8f7cf5a1f956e --- core/pom.xml | 2 +- core/src/main/java/com/facebook/ktfmt/cli/Main.kt | 9 +- .../main/java/com/facebook/ktfmt/cli/ParsedArgs.kt | 16 +- .../java/com/facebook/ktfmt/format/Formatter.kt | 8 +- .../java/com/facebook/ktfmt/format/KotlinInput.kt | 32 +- .../facebook/ktfmt/format/KotlinInputAstVisitor.kt | 426 +- .../java/com/facebook/ktfmt/format/KotlinTok.kt | 6 +- .../ktfmt/format/RedundantElementRemover.kt | 10 +- .../java/com/facebook/ktfmt/kdoc/CommentType.kt | 64 + .../java/com/facebook/ktfmt/kdoc/FormattingTask.kt | 58 + .../com/facebook/ktfmt/kdoc/KDocCommentsHelper.kt | 14 +- .../java/com/facebook/ktfmt/kdoc/KDocFormatter.kt | 305 +- .../facebook/ktfmt/kdoc/KDocFormattingOptions.kt | 134 + .../main/java/com/facebook/ktfmt/kdoc/Paragraph.kt | 617 +++ .../java/com/facebook/ktfmt/kdoc/ParagraphList.kt | 28 + .../facebook/ktfmt/kdoc/ParagraphListBuilder.kt | 830 ++++ .../src/main/java/com/facebook/ktfmt/kdoc/Table.kt | 270 ++ .../main/java/com/facebook/ktfmt/kdoc/Utilities.kt | 329 ++ .../test/java/com/facebook/ktfmt/cli/MainTest.kt | 41 +- .../java/com/facebook/ktfmt/cli/ParsedArgsTest.kt | 81 +- .../com/facebook/ktfmt/format/FormatterTest.kt | 1494 +++++-- .../ktfmt/format/GoogleStyleFormatterKtTest.kt | 383 +- .../java/com/facebook/ktfmt/kdoc/DokkaVerifier.kt | 197 + .../com/facebook/ktfmt/kdoc/KDocFormatterTest.kt | 4706 +++++++++++++++++++- .../java/com/facebook/ktfmt/kdoc/UtilitiesTest.kt | 105 + pom.xml | 2 +- version.txt | 2 +- website/package-lock.json | 66 +- 28 files changed, 9244 insertions(+), 991 deletions(-) create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/CommentType.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/FormattingTask.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/KDocFormattingOptions.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/Paragraph.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphList.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/ParagraphListBuilder.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/Table.kt create mode 100644 core/src/main/java/com/facebook/ktfmt/kdoc/Utilities.kt create mode 100644 core/src/test/java/com/facebook/ktfmt/kdoc/DokkaVerifier.kt create mode 100644 core/src/test/java/com/facebook/ktfmt/kdoc/UtilitiesTest.kt 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 @@ com.facebook ktfmt-parent - 0.39 + 0.42 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=] 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 @@ -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() ?: "" + val fileName = file?.toString() ?: parsedArgs.stdinName ?: "" 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("") + 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}='") + 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() - 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): ImmutableMap { - val builder = LinkedHashMap() - for (tok in toks) { - builder.put(tok.position, tok.column) - } - return ImmutableMap.copyOf(builder) - } + private fun makePositionToColumnMap(toks: List) = + ImmutableMap.copyOf(toks.map { it.position to it.column }.toMap()) private fun buildToks(file: KtFile, fileText: String): ImmutableList { 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): ImmutableRangeMap { + val tokenLocations = ImmutableRangeMap.builder() + 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 `` in `List` */ 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(a, b)`, `foo(a)`, `boo()`, `super(a)` */ + /** + * Examples `foo(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, 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 emitParameterLikeList( - list: List?, - 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 forEachCommaSeparated( - list: Iterable, + private fun visitEachCommaSeparated( + list: Iterable, 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 = 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() 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() - 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, - 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, 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("", 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)) { + // "" or -> "**" + sb.append('*').append('*') + if (s[i] == '/') i++ + i += 2 + continue + } + if (s.startsWith("i>", i, false) || s.startsWith("/i>", i, false)) { + // "" or -> "*" + sb.append('*') + if (s[i] == '/') i++ + i += 2 + continue + } + if (s.startsWith("em>", i, false) || s.startsWith("/em>", i, false)) { + // "" or -> "_" + sb.append('_') + if (s[i] == '/') i++ + i += 3 + continue + } + // (We don't convert
 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 {
+    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 = 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, lineWidth: Int, hangingIndentSize: Int): List {
+    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("
  • ", true) || wordWithSpace.isQuoted()) { + return false + } + } + + return true + } + + private fun computeWords(): List { + 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(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): List { + val count = words.size + val lines = ArrayList() + + val offsets = ArrayList() + 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() + 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 + ): List { + // Greedy implementation + + var width = lineWidth + if (options.hangingIndent > 0 && hanging && continuation) { + width -= getIndentSize(hangingIndent, options) + } + + val lines = mutableListOf() + 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) : Iterable { + fun isSingleParagraph() = paragraphs.size <= 1 + override fun iterator(): Iterator = 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 = 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
     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 
    +    // without a closing 
    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 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("
    ", 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("", "", 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("", "", true).trim()
    +                      if (after.isNotBlank()) {
    +                        newParagraph()
    +                        appendText(after)
    +                        paragraph.preformatted = true
    +                        paragraph.allowEmpty = true
    +                      }
    +                    }
    +
    +                    handleTag("
    ")
    +                    handleTag("
    ") + } + }, + until = { it.contains("
    ", 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("
      ", true) || + lineWithoutIndentation.equals("
        ", true)) { + newParagraph(i - 1).block = true + appendText(lineWithoutIndentation) + newParagraph(i).hanging = true + i = + addLines( + i, + includeEnd = true, + until = { _, w, _ -> w.equals("
    ", true) || w.equals("", true) }, + customize = { _, p -> p.block = true }, + shouldBreak = { w, _ -> + w.startsWith("
  • ", true) || + w.startsWith("", true) || + w.startsWith("", 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("
    ") ||
    +                        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("

    ", true) || + lineWithoutIndentation.startsWith("

    ", true) || + lineWithoutIndentation.startsWith("", true) || + lineWithoutIndentation.equals("

    ", true) || + options.convertMarkup && lineWithoutIndentation.equals("

    ", true)) { + if (options.convertMarkup) { + // Replace

    with a blank line + paragraph.separate = true + } else { + appendText(lineWithoutIndentation) + newParagraph(i).block = true + } + continue + } else if (lineWithoutIndentation.endsWith("", true) || + lineWithoutIndentation.endsWith("", true) || + lineWithoutIndentation.endsWith("", true) || + lineWithoutIndentation.endsWith("", true)) { + if (lineWithoutIndentation.startsWith("", true) || text.startsWith("

    ", 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> { + override fun compare(l1: List, l2: List): 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>() + var tag: MutableList? = 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

     tags where it's implicit)
    +            paragraph.preformatted ->
    +                !prev.preformatted &&
    +                    !text.startsWith("", true) -> false
    +            paragraph.continuation -> true
    +            paragraph.hanging -> false
    +            paragraph.quoted -> prev.quoted
    +            text.isHeader() -> true
    +            text.startsWith("

    ", true) || text.startsWith("

    ", true) -> true + else -> !paragraph.block && !paragraph.isEmpty() + } + + if (paragraph.hanging) { + if (paragraph.doc || text.startsWith("

  • ", 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? = 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().also { levels = it }).add(paragraph.originalIndent) + } + } + } + } + + levels?.sorted()?.let { sorted -> + val assignments = mutableMapOf() + 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, + private val rows: List, + private val align: List, + private val original: List +) { + fun original(): List { + 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 { + val tableMaxWidth = + 2 + widths.sumOf { it + 2 } // +2: "| " in each cell and final " |" on the right + + val pad = tableMaxWidth <= maxWidth + val lines = mutableListOf() + 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, + start: Int, + lineContent: (String) -> String + ): Pair? { + 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() + 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() + 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() + 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() + } +} 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("
  • ", 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(" ") +} + +fun String.isDirectiveMarker(): Boolean { + return 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 { + 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 > Iterable.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 @@ -122,6 +122,21 @@ class MainTest { assertThat(err.toString("UTF-8")).startsWith(":1:14: error: ") } + @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") @@ -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("\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='\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='\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 { + 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(b) { + | c + | } + | foo.bar(baz).zip(b) { + | c + | } + |} + |""" + .trimMargin(), + deduceMaxWidth = true) + + @Test + fun `forward propagation of breaks in call expressions (at value args)`() = + assertFormatted( + """ + |---------------------- + |fun test() { + | foo_bar_baz__zip( + | b) { + | c + | } + |} + | + |fun test() { + | foo.bar(baz).zip( + | 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,20 +1166,54 @@ 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() - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `list of superclasses over multiple lines`() = @@ -1519,20 +1708,25 @@ class FormatterTest { | |class Derived5 : | Super3() - |""".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 { | * | *

    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 |) {} - |""".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, | 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 |) {} - |""".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 | ) |} - |""".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.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 - |""".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 | var x: List.Iterator |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle destructuring declaration in for loop`() = @@ -2721,7 +2951,8 @@ class FormatterTest { |fun f(a: List>) { | 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 where U : @Anno Kip, U : @Anno Qux { | fun bar() where U : @Anno Kip, U : @Anno Qux {} |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `annotations on type arguments`() = - assertFormatted(""" + assertFormatted( + """ |fun foo(x: Foo) {} - |""".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 + | val p = Ctor + |} + |""" + .trimMargin()) @Test fun `handle covariant and contravariant type arguments`() = - assertFormatted(""" + assertFormatted( + """ |val p: Pair - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle covariant and contravariant type parameters`() = - assertFormatted(""" + assertFormatted( + """ |class Foo - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle bounds for type parameters`() = - assertFormatted(""" + assertFormatted( + """ |class Foo, out S : Any?> - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on classes`() = assertFormatted( """ |class Foo(n: Int) where T : Bar, T : FooBar {} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle compound generic bounds on functions`() = assertFormatted( """ |fun 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() : 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 List.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) : Collection 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 -> a } | g { (a, b): List, (c, d): List -> 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 foo2(t: T) { | println(t) |} - |""".trimMargin()) + |""" + .trimMargin()) @Test fun `handle suspended types`() = @@ -3888,7 +4224,8 @@ class FormatterTest { |inline fun foo(noinline block: suspend () -> R): suspend () -> R | |inline fun 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 = Pair, 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::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() 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) { | * 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(b) { + | c + | } + | foo.bar(baz).zip(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( + | b + | ) { + | c + | } + |} + | + |fun test() { + | foo.bar(baz).zip( + | 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, + | com.example.interesting.SomeType< + | com.example.interesting.SomeType, + | 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() + 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("

    ") + val end = rendered.indexOf("
    ", 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) { + 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 + * + *
    +             * line one
    +             * ```
    +             *     line two
    +             * ```
    +             */
    +            """
    +            .trimIndent()
    +    checkFormatter(
    +        source,
    +        KDocFormattingOptions(72),
    +        """
    +            /**
    +             * Some summary
    +             * 
    +             * 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
    +             *
    +             * 
    +             * ```
    +             *     code
    +             * ```
    +             * 
    + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(72), + """ + /** + * Some summary + *
    +             * ```
    +             *     code
    +             * ```
    +             * 
    + */ + """ + .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: + *
    +             *     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(), + //
     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.
    +             * 

    And here's paragraph 3. + *

    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. + * + *

    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 bold, this is italics, but nothing + * should be converted in `code` or in + * ``` + * preformatted text + * ``` + * And this \` is not code and should be converted. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(40).apply { convertMarkup = true }, + """ + /** + * This is **bold**, this is + * *italics*, but nothing should be + * converted in `code` or in + * + * ``` + * preformatted text + * ``` + * + * 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( + """ + /** + *

      + *
    • Incremental merge will never clean the output. + *
    • The inputs must be able to tell which changes to relative files have been made. + *
    • Intermediate state must be saved between merges. + *
    + */ + """ + .trimIndent(), + KDocFormattingOptions(maxLineWidth = 100, maxCommentWidth = 60), + """ + /** + *
      + *
    • Incremental merge will never clean the output. + *
    • The inputs must be able to tell which changes to + * relative files have been made. + *
    • Intermediate state must be saved between merges. + *
    + */ + """ + .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. + *

    + * To generate this, run for example + *

    +             *     ./gradlew :app:dependencies
    +             * 
    + * 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 (

    to ##,

    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.) + *

    + * There are several different common patterns for detecting issues: + *

      + *
    • Checking calls to a given method. For this see + * {@link #getApplicableMethodNames()} and + * {@link #visitMethodCall(JavaContext, UCallExpression, PsiMethod)}
    • + *
    • Instantiating a given class. For this, see + * {@link #getApplicableConstructorTypes()} and + * {@link #visitConstructor(JavaContext, UCallExpression, PsiMethod)}
    • + *
    • Referencing a given constant. For this, see + * {@link #getApplicableReferenceNames()} and + * {@link #visitReference(JavaContext, UReferenceExpression, PsiElement)}
    • + *
    • Extending a given class or implementing a given interface. + * For this, see {@link #applicableSuperClasses()} and + * {@link #visitClass(JavaContext, UClass)}
    • + *
    • 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.
    • + *
    + *

    + * {@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, {@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.) + *

    + *

    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 + * {@link PsiMethod} or {@link PsiField} back. + *

    + * 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)}. + *

    + * Similarly, replace {@link JavaPsiScanner#createPsiVisitor} with {@link SourceCodeScanner#createUastHandler}, + * {@link JavaPsiScanner#getApplicablePsiTypes()} with {@link SourceCodeScanner#getApplicableUastTypes()}, etc. + *

    + * 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. + *

    + * 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. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping between PSI and UAST classes
    PSIUAST
    com.intellij.psi.org.jetbrains.uast.
    IElementTypeUastBinaryOperator
    PsiAnnotationUAnnotation
    PsiAnonymousClassUAnonymousClass
    PsiArrayAccessExpressionUArrayAccessExpression
    PsiBinaryExpressionUBinaryExpression
    PsiCallExpressionUCallExpression
    PsiCatchSectionUCatchClause
    PsiClassUClass
    PsiClassObjectAccessExpressionUClassLiteralExpression
    PsiConditionalExpressionUIfExpression
    PsiDeclarationStatementUDeclarationsExpression
    PsiDoWhileStatementUDoWhileExpression
    PsiElementUElement
    PsiExpressionUExpression
    PsiForeachStatementUForEachExpression
    PsiIdentifierUSimpleNameReferenceExpression
    PsiIfStatementUIfExpression
    PsiImportStatementUImportStatement
    PsiImportStaticStatementUImportStatement
    PsiJavaCodeReferenceElementUReferenceExpression
    PsiLiteralULiteralExpression
    PsiLocalVariableULocalVariable
    PsiMethodUMethod
    PsiMethodCallExpressionUCallExpression
    PsiNameValuePairUNamedExpression
    PsiNewExpressionUCallExpression
    PsiParameterUParameter
    PsiParenthesizedExpressionUParenthesizedExpression
    PsiPolyadicExpressionUPolyadicExpression
    PsiPostfixExpressionUPostfixExpression or UUnaryExpression
    PsiPrefixExpressionUPrefixExpression or UUnaryExpression
    PsiReferenceUReferenceExpression
    PsiReferenceUResolvable
    PsiReferenceExpressionUReferenceExpression
    PsiReturnStatementUReturnExpression
    PsiSuperExpressionUSuperExpression
    PsiSwitchLabelStatementUSwitchClauseExpression
    PsiSwitchStatementUSwitchExpression
    PsiThisExpressionUThisExpression
    PsiThrowStatementUThrowExpression
    PsiTryStatementUTryExpression
    PsiTypeCastExpressionUBinaryExpressionWithType
    PsiWhileStatementUWhileExpression
    + * 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 {@link 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 + * {@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: + *
    +             *     UastContext context = UastUtils.getUastContext(element);
    +             *     UExpression body = context.getMethodBody(method);
    +             * 
    + * Similarly if you have a {@link 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 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 {@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. + * + *

    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: + * {@link ResourceReference}. Here's an example of code which has a {@link 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 {@link PsiBinaryExpression} for things like checking comparator + * operators or arithmetic combination of operands, you can replace this with + * {@link UBinaryExpression}. But you normally shouldn't; you should use + * {@link 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 {@link 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. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping between PSI and UAST method names
    PSIUAST
    getArgumentListgetValueArguments
    getCatchSectionsgetCatchClauses
    getDeclaredElementsgetDeclarations
    getElseBranchgetElseExpression
    getInitializergetUastInitializer
    getLExpressiongetLeftOperand
    getOperationTokenTypegetOperator
    getOwnergetUastParent
    getParentgetUastParent
    getRExpressiongetRightOperand
    getReturnValuegetReturnExpression
    getTextasSourceString
    getThenBranchgetThenExpression
    getTypegetExpressionType
    getTypeParametersgetTypeArguments
    resolveMethodresolve
    + *

    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 {@link #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, {@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. + * + *

    + *

    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 {@link SourceCodeScanner} so a lot of the same concepts + * apply; then follow the above section. + *

    + */ + """ + .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: + *

      + *
    • Checking calls to a given method. For this see [getApplicableMethodNames] and [visitMethodCall]
    • + *
    • Instantiating a given class. For this, see [getApplicableConstructorTypes] and [visitConstructor]
    • + *
    • Referencing a given constant. For this, see [getApplicableReferenceNames] and [visitReference]
    • + *
    • Extending a given class or implementing a given interface. For this, see [applicableSuperClasses] and + * [visitClass]
    • + *
    • 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.
    • + *
    + * + * {@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. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping between PSI and UAST classes
    PSIUAST
    com.intellij.psi.org.jetbrains.uast.
    IElementTypeUastBinaryOperator
    PsiAnnotationUAnnotation
    PsiAnonymousClassUAnonymousClass
    PsiArrayAccessExpressionUArrayAccessExpression
    PsiBinaryExpressionUBinaryExpression
    PsiCallExpressionUCallExpression
    PsiCatchSectionUCatchClause
    PsiClassUClass
    PsiClassObjectAccessExpressionUClassLiteralExpression
    PsiConditionalExpressionUIfExpression
    PsiDeclarationStatementUDeclarationsExpression
    PsiDoWhileStatementUDoWhileExpression
    PsiElementUElement
    PsiExpressionUExpression
    PsiForeachStatementUForEachExpression
    PsiIdentifierUSimpleNameReferenceExpression
    PsiIfStatementUIfExpression
    PsiImportStatementUImportStatement
    PsiImportStaticStatementUImportStatement
    PsiJavaCodeReferenceElementUReferenceExpression
    PsiLiteralULiteralExpression
    PsiLocalVariableULocalVariable
    PsiMethodUMethod
    PsiMethodCallExpressionUCallExpression
    PsiNameValuePairUNamedExpression
    PsiNewExpressionUCallExpression
    PsiParameterUParameter
    PsiParenthesizedExpressionUParenthesizedExpression
    PsiPolyadicExpressionUPolyadicExpression
    PsiPostfixExpressionUPostfixExpression or UUnaryExpression
    PsiPrefixExpressionUPrefixExpression or UUnaryExpression
    PsiReferenceUReferenceExpression
    PsiReferenceUResolvable
    PsiReferenceExpressionUReferenceExpression
    PsiReturnStatementUReturnExpression
    PsiSuperExpressionUSuperExpression
    PsiSwitchLabelStatementUSwitchClauseExpression
    PsiSwitchStatementUSwitchExpression
    PsiThisExpressionUThisExpression
    PsiThrowStatementUThrowExpression
    PsiTryStatementUTryExpression
    PsiTypeCastExpressionUBinaryExpressionWithType
    PsiWhileStatementUWhileExpression
    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 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. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping between PSI and UAST method names
    PSIUAST
    getArgumentListgetValueArguments
    getCatchSectionsgetCatchClauses
    getDeclaredElementsgetDeclarations
    getElseBranchgetElseExpression
    getInitializergetUastInitializer
    getLExpressiongetLeftOperand
    getOperationTokenTypegetOperator
    getOwnergetUastParent
    getParentgetUastParent
    getRExpressiongetRightOperand
    getReturnValuegetReturnExpression
    getTextasSourceString
    getThenBranchgetThenExpression
    getTypegetExpressionType
    getTypeParametersgetTypeArguments
    resolveMethodresolve
    + * + * ### 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

    , it's preserved. + val source = + """ + /** + *

      + *
    • test
    • + *
    + *

    + * After. + */ + """ + .trimIndent() + checkFormatter( + source, + KDocFormattingOptions(120, 120), + """ + /** + *

      + *
    • test
    • + *
    + * + * 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 = + """ + /** + * + * + * + * + * + * ```kotlin + * fun exit(): Nothing = exitProcess(0) + * ``` + * + * + * + * + * + * + * + * + * [captureOutput]: https://example.com/kotlinx-knit-test/kotlinx.knit.test/capture-output.html + * + * + * Make sure we never line break + * + * + * + * ```kotlin + * fun exit(): Nothing = exitProcess(0) + * ``` + * + * + * + * + * + * + * + * + * [captureOutput]: + * https://example.com/kotlinx-knit-test/kotlinx.knit.test/capture-output.html + * + * + * Make sure we never line break