Skip to content

Commit 22d1bcf

Browse files
fix: fix trailing newline issue for kotlinFile. Add test.
* Add trailing newline tests * fix: Whitespace.countTerminalNewlines is subtly different for kotlinFile vs script. * Make trim consistent with other tests --------- Co-authored-by: Tony Robalik <[email protected]>
1 parent 0d1b3ac commit 22d1bcf

File tree

2 files changed

+100
-23
lines changed

2 files changed

+100
-23
lines changed

core/src/main/kotlin/cash/grammar/kotlindsl/utils/Whitespace.kt

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import com.squareup.cash.grammar.KotlinLexer
44
import com.squareup.cash.grammar.KotlinParser.KotlinFileContext
55
import com.squareup.cash.grammar.KotlinParser.ScriptContext
66
import com.squareup.cash.grammar.KotlinParser.SemiContext
7+
import com.squareup.cash.grammar.KotlinParser.SemisContext
78
import org.antlr.v4.runtime.CommonTokenStream
89
import org.antlr.v4.runtime.ParserRuleContext
910
import org.antlr.v4.runtime.Token
11+
import org.antlr.v4.runtime.tree.ParseTree
1012

1113
/**
1214
* Utilities for working with whitespace, including newlines, carriage returns, etc.
@@ -119,49 +121,67 @@ public object Whitespace {
119121
* Use this in conjunction with [trimGently] to maintain original end-of-file formatting.
120122
*/
121123
public fun countTerminalNewlines(ctx: ScriptContext, tokens: CommonTokenStream): Int {
122-
return countTerminalNewlines(ctx as ParserRuleContext, tokens)
124+
return ctx.children
125+
// Start iterating from EOF
126+
.reversed()
127+
.asSequence()
128+
// Drop `EOF` (every file must have this)
129+
.drop(1)
130+
// Take only the "semis", which is a semi-colon or a newline, followed by 0 or more newlines
131+
.takeWhile { parseTree ->
132+
parseTree.javaClass == SemiContext::class.java && hasNoComments(parseTree, tokens)
133+
}
134+
// Note this is a `SemiContext` (singular!)
135+
.filterIsInstance<SemiContext>()
136+
// This is the "a newline, followed by 0 or more newlines"
137+
.flatMap { it.NL() }
138+
.count()
123139
}
124140

125141
/**
126142
* Use this in conjunction with [trimGently] to maintain original end-of-file formatting.
127143
*/
128144
public fun countTerminalNewlines(ctx: KotlinFileContext, tokens: CommonTokenStream): Int {
129-
return countTerminalNewlines(ctx as ParserRuleContext, tokens)
130-
}
131-
132-
private fun countTerminalNewlines(ctx: ParserRuleContext, tokens: CommonTokenStream): Int {
133145
return ctx.children
134146
// Start iterating from EOF
135147
.reversed()
136148
.asSequence()
137149
// Drop `EOF` (every file must have this)
138150
.drop(1)
151+
.filterIsInstance<ParserRuleContext>()
152+
// We need to reverse the order of the children too. If a node doesn't have children, use it.
153+
.flatMap { it.children?.reversed() ?: listOf(it) }
139154
// Take only the "semis", which is a semi-colon or a newline, followed by 0 or more newlines
140155
.takeWhile { parseTree ->
141-
// Because comments are not part of the parse tree (they are shunted to a "hidden" channel),
142-
// we need to check for them. Otherwise, we'll "detect" too many newlines at the end of a
143-
// file, when that file has only comments and newlines at the end.
144-
val hasNoComments = if (parseTree is ParserRuleContext) {
145-
val toLeft = parseTree.stop?.let { stop ->
146-
tokens.getHiddenTokensToLeft(stop.tokenIndex).orEmpty().isEmpty()
147-
} ?: true
148-
val toRight = parseTree.stop?.let { stop ->
149-
tokens.getHiddenTokensToRight(stop.tokenIndex).orEmpty().isEmpty()
150-
} ?: true
151-
152-
toLeft && toRight
153-
} else {
154-
true
155-
}
156-
157-
parseTree.javaClass == SemiContext::class.java && hasNoComments
156+
parseTree.javaClass == SemisContext::class.java && hasNoComments(parseTree, tokens)
158157
}
159-
.filterIsInstance<SemiContext>()
158+
// Note this is a `SemisContext` (plural!)
159+
.filterIsInstance<SemisContext>()
160160
// This is the "a newline, followed by 0 or more newlines"
161161
.flatMap { it.NL() }
162162
.count()
163163
}
164164

165+
/**
166+
* Because comments are not part of the parse tree (they are shunted to a "hidden" channel),
167+
* we need to check for them. Otherwise, we'll "detect" too many newlines at the end of a
168+
* file, when that file has only comments and newlines at the end.
169+
*/
170+
private fun hasNoComments(parseTree: ParseTree, tokens: CommonTokenStream): Boolean {
171+
return if (parseTree is ParserRuleContext) {
172+
val toLeft = parseTree.stop?.let { stop ->
173+
tokens.getHiddenTokensToLeft(stop.tokenIndex).orEmpty().isEmpty()
174+
} ?: true
175+
val toRight = parseTree.stop?.let { stop ->
176+
tokens.getHiddenTokensToRight(stop.tokenIndex).orEmpty().isEmpty()
177+
} ?: true
178+
179+
toLeft && toRight
180+
} else {
181+
true
182+
}
183+
}
184+
165185
/**
166186
* Use this in conjunction with [countTerminalNewlines] to maintain original end-of-file
167187
* formatting.

core/src/test/kotlin/cash/grammar/kotlindsl/utils/WhitespaceTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import cash.grammar.kotlindsl.parse.Parser
44
import cash.grammar.kotlindsl.utils.Blocks.isBuildscript
55
import cash.grammar.kotlindsl.utils.Blocks.isSubprojects
66
import cash.grammar.kotlindsl.utils.test.TestErrorListener
7+
import com.squareup.cash.grammar.KotlinParser
78
import com.squareup.cash.grammar.KotlinParser.NamedBlockContext
89
import com.squareup.cash.grammar.KotlinParserBaseListener
910
import org.antlr.v4.runtime.CommonTokenStream
@@ -74,12 +75,68 @@ internal class WhitespaceTest {
7475
.allSatisfy { assertThat(it).isEqualTo(tuple(" ")) }
7576
}
7677

78+
@Test fun `gets trailing newlines for buildscript`() {
79+
val buildScript =
80+
"""
81+
plugins {
82+
id("kotlin")
83+
}
84+
85+
subprojects {
86+
buildscript {
87+
repositories {}
88+
}
89+
}
90+
91+
""".trimIndent()
92+
93+
val scriptListener = Parser(
94+
file = buildScript,
95+
errorListener = TestErrorListener {
96+
throw RuntimeException("Syntax error: ${it?.message}", it)
97+
},
98+
listenerFactory = { _, tokens, _ -> TestListener(tokens) }
99+
).listener()
100+
101+
assertThat(scriptListener.trailingBuildscriptNewlines).isEqualTo(1)
102+
}
103+
104+
@Test fun `gets trailing newlines for kotlin file`() {
105+
val file =
106+
"""
107+
class Foo {
108+
}
109+
110+
""".trimIndent()
111+
112+
val scriptListener = Parser(
113+
file = file.byteInputStream(),
114+
errorListener = TestErrorListener {
115+
throw RuntimeException("Syntax error: ${it?.message}", it)
116+
},
117+
startRule = { parser -> parser.kotlinFile() },
118+
listenerFactory = { _, tokens, _ -> TestListener(tokens) }
119+
).listener()
120+
121+
assertThat(scriptListener.trailingKotlinFileNewlines).isEqualTo(1)
122+
}
123+
77124
private class TestListener(
78125
private val tokens: CommonTokenStream
79126
) : KotlinParserBaseListener() {
80127

81128
var newlines: List<Token>? = null
82129
var whitespace: List<Token>? = null
130+
var trailingBuildscriptNewlines = 0
131+
var trailingKotlinFileNewlines = 0
132+
133+
override fun enterScript(ctx: KotlinParser.ScriptContext) {
134+
trailingBuildscriptNewlines = Whitespace.countTerminalNewlines(ctx, tokens)
135+
}
136+
137+
override fun enterKotlinFile(ctx: KotlinParser.KotlinFileContext) {
138+
trailingKotlinFileNewlines = Whitespace.countTerminalNewlines(ctx, tokens)
139+
}
83140

84141
override fun exitNamedBlock(ctx: NamedBlockContext) {
85142
if (ctx.isSubprojects) {

0 commit comments

Comments
 (0)