Skip to content

Commit 1b07654

Browse files
authored
Merge pull request #2514 from ahoppen/ahoppen/infer-indentation
Add API to infer the indentation of a syntax tree
2 parents d36f0c1 + ba7518c commit 1b07654

File tree

4 files changed

+328
-0
lines changed

4 files changed

+328
-0
lines changed

Release Notes/600.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
- Issue: https://github.com/apple/swift-syntax/issues/2031
5555
- Pull Request: https://github.com/apple/swift-syntax/pull/2327
5656

57+
- `BasicFormat.inferIndentation(of:)`
58+
- Description: Uses heuristics to infer the indentation width used in a syntax tree.
59+
- Pull Request: https://github.com/apple/swift-syntax/pull/2514
60+
5761
## API Behavior Changes
5862

5963
## Deprecations

Sources/SwiftBasicFormat/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
add_swift_syntax_library(SwiftBasicFormat
1010
BasicFormat.swift
11+
InferIndentation.swift
1112
Syntax+Extensions.swift
1213
SyntaxProtocol+Formatted.swift
1314
Trivia+FormatExtensions.swift
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftSyntax
14+
15+
extension BasicFormat {
16+
/// Uses heuristics to infer the indentation width used in the given syntax tree.
17+
///
18+
/// Returns `nil` if the indentation could not be inferred, eg. because it is inconsistent or there are not enough
19+
/// indented lines to infer the indentation with sufficient accuracy.
20+
public static func inferIndentation(of tree: some SyntaxProtocol) -> Trivia? {
21+
return IndentationInferrer.inferIndentation(of: tree)
22+
}
23+
}
24+
25+
private class IndentationInferrer: SyntaxVisitor {
26+
/// The trivia of the previous visited token.
27+
///
28+
/// The previous token's trailing trivia will be concatenated with the current token's leading trivia to infer
29+
/// indentation.
30+
///
31+
/// We start with .newline to indicate that the first token starts on a newline, even if it technically doesn't have
32+
/// a leading newline character.
33+
private var previousTokenTrailingTrivia: Trivia = .newline
34+
35+
/// Counts how many lines had how many spaces of indentation.
36+
///
37+
/// For example, spaceIndentedLines[2] = 4 means that for lines had exactly 2 spaces of indentation.
38+
private var spaceIndentedLines: [Int: Int] = [:]
39+
40+
/// See `spaceIndentedLines`
41+
private var tabIndentedLines: [Int: Int] = [:]
42+
43+
/// The number of lines that were processed for indentation inference.
44+
///
45+
/// This will be lower than the actual number of lines in the syntax node because
46+
/// - It does not count lines without indentation
47+
//// - It does not count newlines in block doc comments (because we don't process the comment's contents)
48+
private var linesProcessed = 0
49+
50+
override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind {
51+
defer { previousTokenTrailingTrivia = token.trailingTrivia }
52+
let triviaAtStartOfLine =
53+
(previousTokenTrailingTrivia + token.leadingTrivia)
54+
.drop(while: { !$0.isNewline }) // Ignore any trivia that's on the previous line
55+
.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) // Split trivia into the lines it occurs on
56+
.dropFirst() // Drop the first empty array; exists because we dropped non-newline prefix and newline is separator
57+
58+
LINE_TRIVIA_LOOP: for lineTrivia in triviaAtStartOfLine {
59+
switch lineTrivia.first {
60+
case .spaces(var spaces):
61+
linesProcessed += 1
62+
for triviaPiece in lineTrivia.dropFirst() {
63+
switch triviaPiece {
64+
case .spaces(let followupSpaces): spaces += followupSpaces
65+
case .tabs: break LINE_TRIVIA_LOOP // Count as processed line but don't add to any indentation count
66+
default: break
67+
}
68+
}
69+
spaceIndentedLines[spaces, default: 0] += 1
70+
case .tabs(var tabs):
71+
linesProcessed += 1
72+
for triviaPiece in lineTrivia.dropFirst() {
73+
switch triviaPiece {
74+
case .tabs(let followupTabs): tabs += followupTabs
75+
case .spaces: break LINE_TRIVIA_LOOP // Count as processed line but don't add to any indentation count
76+
default: break
77+
}
78+
}
79+
tabIndentedLines[tabs, default: 0] += 1
80+
default:
81+
break
82+
}
83+
}
84+
return .skipChildren
85+
}
86+
87+
static func inferIndentation(of tree: some SyntaxProtocol) -> Trivia? {
88+
let visitor = IndentationInferrer(viewMode: .sourceAccurate)
89+
visitor.walk(tree)
90+
if visitor.linesProcessed < 3 {
91+
// We don't have enough lines to infer indentation reliably
92+
return nil
93+
}
94+
95+
// Pick biggest indentation that encompasses at least 90% of the source lines.
96+
let threshold = Int(Double(visitor.linesProcessed) * 0.9)
97+
98+
for spaceIndentation in [8, 4, 2] {
99+
let linesMatchingIndentation = visitor
100+
.spaceIndentedLines
101+
.filter { $0.key.isMultiple(of: spaceIndentation) }
102+
.map { $0.value }
103+
.sum
104+
if linesMatchingIndentation > threshold {
105+
return .spaces(spaceIndentation)
106+
}
107+
}
108+
109+
for tabIndentation in [2, 1] {
110+
let linesMatchingIndentation = visitor
111+
.tabIndentedLines
112+
.filter { $0.key.isMultiple(of: tabIndentation) }
113+
.map { $0.value }
114+
.sum
115+
if linesMatchingIndentation > threshold {
116+
return .tabs(tabIndentation)
117+
}
118+
}
119+
return nil
120+
}
121+
}
122+
123+
fileprivate extension Array<Int> {
124+
var sum: Int {
125+
return self.reduce(0) { return $0 + $1 }
126+
}
127+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftBasicFormat
14+
import SwiftSyntax
15+
import SwiftSyntaxBuilder
16+
import XCTest
17+
18+
fileprivate func assertIndentation(
19+
of sourceFile: SourceFileSyntax,
20+
_ expected: Trivia?,
21+
file: StaticString = #file,
22+
line: UInt = #line
23+
) {
24+
let inferred = BasicFormat.inferIndentation(of: sourceFile)
25+
XCTAssertEqual(inferred, expected, file: file, line: line)
26+
}
27+
28+
final class InferIndentationTests: XCTestCase {
29+
func testTwoSpaceIndentation() {
30+
assertIndentation(
31+
of: """
32+
class Foo {
33+
func bar() {
34+
print("hello world")
35+
}
36+
}
37+
""",
38+
.spaces(2)
39+
)
40+
}
41+
42+
func testTwoSpaceIndentationWithMultipleFourSpaceLines() {
43+
assertIndentation(
44+
of: """
45+
class Foo {
46+
func bar() {
47+
print("hello world")
48+
print("test")
49+
print("another")
50+
}
51+
}
52+
""",
53+
.spaces(2)
54+
)
55+
}
56+
57+
func testTabIndentation() {
58+
assertIndentation(
59+
of: """
60+
class Foo {
61+
\tfunc bar() {
62+
\tprint("hello world")
63+
\t}
64+
}
65+
""",
66+
.tabs(1)
67+
)
68+
}
69+
70+
func testUseLineCommentsForInference() {
71+
assertIndentation(
72+
of: """
73+
class Foo {
74+
/// Some doc comment
75+
/// And another
76+
func bar() {
77+
}
78+
}
79+
""",
80+
.spaces(2)
81+
)
82+
}
83+
84+
func testBlockCommentContentsDontCountForInference() {
85+
assertIndentation(
86+
of: """
87+
class Foo {
88+
/*
89+
* Test abc
90+
*/
91+
func bar() {}
92+
}
93+
""",
94+
nil
95+
)
96+
}
97+
98+
func testWithBlockComment() {
99+
assertIndentation(
100+
of: """
101+
class Foo {
102+
/*
103+
* Test abc
104+
*/
105+
func bar() {
106+
print("test")
107+
}
108+
}
109+
""",
110+
.spaces(2)
111+
)
112+
}
113+
114+
func testDontGetConfusedBySingleIncorrectIndentation() {
115+
assertIndentation(
116+
of: """
117+
class Foo {
118+
func bar() {
119+
print("1")
120+
print("2")
121+
print("3")
122+
print("4")
123+
print("5")
124+
print("6")
125+
print("7")
126+
print("8")
127+
print("9")
128+
print("10")
129+
}
130+
}
131+
""",
132+
.spaces(4)
133+
)
134+
}
135+
136+
func testMixedTwoAndFourSpaceIndentation() {
137+
assertIndentation(
138+
of: """
139+
class Foo {
140+
func bar() {
141+
print("1")
142+
print("2")
143+
print("3")
144+
print("4")
145+
print("5")
146+
print("6")
147+
print("7")
148+
print("8")
149+
print("9")
150+
print("10")
151+
}
152+
}
153+
""",
154+
.spaces(2)
155+
)
156+
}
157+
158+
func testMixedSpaceAndTabIndentation() {
159+
assertIndentation(
160+
of: """
161+
class Foo {
162+
func bar() {
163+
print("1")
164+
print("2")
165+
print("3")
166+
print("4")
167+
print("5")
168+
\t\tprint("6")
169+
\t\tprint("7")
170+
\t\tprint("8")
171+
\t\tprint("9")
172+
\t\tprint("10")
173+
\t}
174+
}
175+
""",
176+
nil
177+
)
178+
}
179+
180+
func testMultilineStringLiteralWithInternalIndentation() {
181+
assertIndentation(
182+
of: #"""
183+
class Foo {
184+
func bar() {
185+
print("""
186+
To Do:
187+
- Ignore string literals
188+
- Infer indentation
189+
""")
190+
}
191+
}
192+
"""#,
193+
.spaces(2)
194+
)
195+
}
196+
}

0 commit comments

Comments
 (0)