-
Notifications
You must be signed in to change notification settings - Fork 441
/
Copy pathInferIndentation.swift
131 lines (118 loc) · 4.76 KB
/
InferIndentation.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
#if swift(>=6)
public import SwiftSyntax
#else
import SwiftSyntax
#endif
extension BasicFormat {
/// Uses heuristics to infer the indentation width used in the given syntax tree.
///
/// Returns `nil` if the indentation could not be inferred, eg. because it is inconsistent or there are not enough
/// indented lines to infer the indentation with sufficient accuracy.
public static func inferIndentation(of tree: some SyntaxProtocol) -> Trivia? {
return IndentationInferrer.inferIndentation(of: tree)
}
}
private class IndentationInferrer: SyntaxVisitor {
/// The trivia of the previous visited token.
///
/// The previous token's trailing trivia will be concatenated with the current token's leading trivia to infer
/// indentation.
///
/// We start with .newline to indicate that the first token starts on a newline, even if it technically doesn't have
/// a leading newline character.
private var previousTokenTrailingTrivia: Trivia = .newline
/// Counts how many lines had how many spaces of indentation.
///
/// For example, spaceIndentedLines[2] = 4 means that for lines had exactly 2 spaces of indentation.
private var spaceIndentedLines: [Int: Int] = [:]
/// See `spaceIndentedLines`
private var tabIndentedLines: [Int: Int] = [:]
/// The number of lines that were processed for indentation inference.
///
/// This will be lower than the actual number of lines in the syntax node because
/// - It does not count lines without indentation
//// - It does not count newlines in block doc comments (because we don't process the comment's contents)
private var linesProcessed = 0
override func visit(_ token: TokenSyntax) -> SyntaxVisitorContinueKind {
defer { previousTokenTrailingTrivia = token.trailingTrivia }
let triviaAtStartOfLine =
(previousTokenTrailingTrivia + token.leadingTrivia)
.drop(while: { !$0.isNewline }) // Ignore any trivia that's on the previous line
.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) // Split trivia into lines
.dropFirst() // Drop the first empty array; because we dropped non-newline prefix and newline is separator
LINE_TRIVIA_LOOP: for lineTrivia in triviaAtStartOfLine {
switch lineTrivia.first {
case .spaces(var spaces):
linesProcessed += 1
for triviaPiece in lineTrivia.dropFirst() {
switch triviaPiece {
case .spaces(let followupSpaces): spaces += followupSpaces
case .tabs: break LINE_TRIVIA_LOOP // Count as processed line but don't add to any indentation count
default: break
}
}
spaceIndentedLines[spaces, default: 0] += 1
case .tabs(var tabs):
linesProcessed += 1
for triviaPiece in lineTrivia.dropFirst() {
switch triviaPiece {
case .tabs(let followupTabs): tabs += followupTabs
case .spaces: break LINE_TRIVIA_LOOP // Count as processed line but don't add to any indentation count
default: break
}
}
tabIndentedLines[tabs, default: 0] += 1
default:
break
}
}
return .skipChildren
}
static func inferIndentation(of tree: some SyntaxProtocol) -> Trivia? {
let visitor = IndentationInferrer(viewMode: .sourceAccurate)
visitor.walk(tree)
if visitor.linesProcessed < 3 {
// We don't have enough lines to infer indentation reliably
return nil
}
// Pick biggest indentation that encompasses at least 90% of the source lines.
let threshold = Int(Double(visitor.linesProcessed) * 0.9)
for spaceIndentation in [8, 4, 2] {
let linesMatchingIndentation = visitor
.spaceIndentedLines
.filter { $0.key.isMultiple(of: spaceIndentation) }
.map { $0.value }
.sum
if linesMatchingIndentation > threshold {
return .spaces(spaceIndentation)
}
}
for tabIndentation in [2, 1] {
let linesMatchingIndentation = visitor
.tabIndentedLines
.filter { $0.key.isMultiple(of: tabIndentation) }
.map { $0.value }
.sum
if linesMatchingIndentation > threshold {
return .tabs(tabIndentation)
}
}
return nil
}
}
fileprivate extension Array<Int> {
var sum: Int {
return self.reduce(0) { return $0 + $1 }
}
}