Skip to content

Wrap multiline string literals to line length. #792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/SwiftFormat/API/Configuration+Default.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ extension Configuration {
self.spacesAroundRangeFormationOperators = false
self.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration()
self.multiElementCollectionTrailingCommas = true
self.reflowMultilineStringLiterals = .never
}
}
71 changes: 71 additions & 0 deletions Sources/SwiftFormat/API/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public struct Configuration: Codable, Equatable {
case spacesAroundRangeFormationOperators
case noAssignmentInExpressions
case multiElementCollectionTrailingCommas
case reflowMultilineStringLiterals
}

/// A dictionary containing the default enabled/disabled states of rules, keyed by the rules'
Expand Down Expand Up @@ -194,6 +195,71 @@ public struct Configuration: Codable, Equatable {
/// ```
public var multiElementCollectionTrailingCommas: Bool

/// Determines how multiline string literals should reflow when formatted.
public enum MultilineStringReflowBehavior: Codable {
/// Never reflow multiline string literals.
case never
/// Reflow lines in string literal that exceed the maximum line length. For example with a line length of 10:
/// ```swift
/// """
/// an escape\
/// line break
/// a hard line break
/// """
/// ```
/// will be formatted as:
/// ```swift
/// """
/// an esacpe\
/// line break
/// a hard \
/// line break
/// """
/// ```
/// The existing `\` is left in place, but the line over line length is broken.
case onlyLinesOverLength
/// Always reflow multiline string literals, this will ignore existing escaped newlines in the literal and reflow each line. Hard linebreaks are still respected.
/// For example, with a line length of 10:
/// ```swift
/// """
/// one \
/// word \
/// a line.
/// this is too long.
/// """
/// ```
/// will be formatted as:
/// ```swift
/// """
/// one word \
/// a line.
/// this is \
/// too long.
/// """
/// ```
case always

var isNever: Bool {
switch self {
case .never:
return true
default:
return false
}
}

var isAlways: Bool {
switch self {
case .always:
return true
default:
return false
}
}
}

public var reflowMultilineStringLiterals: MultilineStringReflowBehavior

/// Creates a new `Configuration` by loading it from a configuration file.
public init(contentsOf url: URL) throws {
let data = try Data(contentsOf: url)
Expand Down Expand Up @@ -287,6 +353,10 @@ public struct Configuration: Codable, Equatable {
Bool.self, forKey: .multiElementCollectionTrailingCommas)
?? defaults.multiElementCollectionTrailingCommas

self.reflowMultilineStringLiterals =
try container.decodeIfPresent(MultilineStringReflowBehavior.self, forKey: .reflowMultilineStringLiterals)
?? defaults.reflowMultilineStringLiterals

// If the `rules` key is not present at all, default it to the built-in set
// so that the behavior is the same as if the configuration had been
// default-initialized. To get an empty rules dictionary, one can explicitly
Expand Down Expand Up @@ -321,6 +391,7 @@ public struct Configuration: Codable, Equatable {
try container.encode(indentSwitchCaseLabels, forKey: .indentSwitchCaseLabels)
try container.encode(noAssignmentInExpressions, forKey: .noAssignmentInExpressions)
try container.encode(multiElementCollectionTrailingCommas, forKey: .multiElementCollectionTrailingCommas)
try container.encode(reflowMultilineStringLiterals, forKey: .reflowMultilineStringLiterals)
try container.encode(rules, forKey: .rules)
}

Expand Down
55 changes: 45 additions & 10 deletions Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ public class PrettyPrinter {
switch token {
case .contextualBreakingStart:
activeBreakingContexts.append(ActiveBreakingContext(lineNumber: outputBuffer.lineNumber))

// Discard the last finished breaking context to keep it from effecting breaks inside of the
// new context. The discarded context has already either had an impact on the contextual break
// after it or there was no relevant contextual break, so it's safe to discard.
Expand Down Expand Up @@ -414,7 +413,7 @@ public class PrettyPrinter {

var overrideBreakingSuppressed = false
switch newline {
case .elective: break
case .elective, .escaped: break
case .soft(_, let discretionary):
// A discretionary newline (i.e. from the source) should create a line break even if the
// rules for breaking are disabled.
Expand All @@ -429,6 +428,10 @@ public class PrettyPrinter {
let suppressBreaking = isBreakingSuppressed && !overrideBreakingSuppressed
if !suppressBreaking && (!canFit(length) || mustBreak) {
currentLineIsContinuation = isContinuationIfBreakFires
if case .escaped = newline {
outputBuffer.enqueueSpaces(size)
outputBuffer.write("\\")
}
outputBuffer.writeNewlines(newline)
lastBreak = true
} else {
Expand Down Expand Up @@ -594,19 +597,51 @@ public class PrettyPrinter {
// Break lengths are equal to its size plus the token or group following it. Calculate the
// length of any prior break tokens.
case .break(_, let size, let newline):
if let index = delimIndexStack.last, case .break = tokens[index] {
lengths[index] += total
if let index = delimIndexStack.last, case .break(_, _, let lastNewline) = tokens[index] {
/// If the last break and this break are both `.escaped` we add an extra 1 to the total for the last `.escaped` break.
/// This is to handle situations where adding the `\` for an escaped line break would put us over the line length.
/// For example, consider the token sequence:
/// `[.syntax("this fits"), .break(.escaped), .syntax("this fits in line length"), .break(.escaped)]`
/// The naive layout of these tokens will incorrectly print as:
/// """
/// this fits this fits in line length \
/// """
/// which will be too long because of the '\' character. Instead we have to print it as:
/// """
/// this fits \
/// this fits in line length
/// """
///
/// While not prematurely inserting a line in situations where a hard line break is occurring, such as:
///
/// `[.syntax("some text"), .break(.escaped), .syntax("this is exactly the right length"), .break(.hard)]`
///
/// We want this to print as:
/// """
/// some text this is exactly the right length
/// """
/// and not:
/// """
/// some text \
/// this is exactly the right length
Comment on lines +619 to +626
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since backslashes are used to suppress newlines, I think I agree with this choice. I'll admit it's somewhat opinionated though. I don't know if other folks would want to be able to choose where in a string they break it. If we made this configurable, we could allow that, but the problem with doing that is that once a string is broken in this way, it can never be automatically unbroken, so shifting indentation could cause a string to split awkwardly.

@ahoppen @bnbarham Do either of you have opinions here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally would not automatically re-flow string literals. Most likely a user already put thought into the re-flow. If you do want to re-flow, I personally think it’s not too complicated to change the string literal to be on a single line again and then let swift-format break it again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely a user already put thought into the re-flow

The main case that this isn't true is what @allevato mentioned, ie. format causes a split, now you indent for some other reason (nest in an if/whatever), and now never automatically reflow.

I personally think it’s not too complicated to change the string literal to be on a single line again

Could have a refactoring for this pretty easily too.


I'm somewhat split in my opinion as I can see benefits in both. I definitely think we need to have an option (probably defaulted to off) if we do reflow though. Maybe // swift-format-ignore is enough for cases when it's on?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an option to configure multiline string reflow behavior. When false, existing escaped newlines are left in place and string reflow does not occur. When true, existing escaped newlines are removed and string reflows using the inserted .escaped break tokens.

The option defaults to false, so reflow will not occur by default and will be opt-in. // swift-format-ignore works regardless of this setting, so folks can turn on reflow and still keep escaped newlines for specific literals.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spoke with @allevato and extended the reflow config behavior to include 3 options: never, onlyLinesOverLength, and always.

  • never behaves the same as today, we never break up multiline strings.
  • onlyLinesOverLength respects existing escaped line breaks but will break up lines over the line length.
  • always does not respect existing escaped line breaks. It will remove existing escaped line breaks and reflow everything.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After chatting offline, we decided on the above and a couple other changes:

  1. Keep the default mode as never, to preserve existing behavior (don't break or reflow strings)
  2. Even when reflowing is onlyLinesOverLength or always, only do it for regular multiline strings ("""), never for raw multiline strings (#""", etc.)

The rationale for (2) is that it's highly likely that raw multiline strings are used for things like structured data, code, etc., whereas regular multiline strings are more likely to be simple prose.

That's not always the case, of course; swift-syntax and swift-format both have a mixture of code snippets in regular and raw multiline strings. But if we wanted to change the default behavior for this later, we could update those so that they format correctly in each case.

@ahoppen @bnbarham Do you think this is a reasonable landing spot for the time being?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable to me! Also CC @shawnhyam

/// """
if case .escaped = newline, case .escaped = lastNewline {
lengths[index] += total + 1
} else {
lengths[index] += total
}
delimIndexStack.removeLast()
}
lengths.append(-total)
delimIndexStack.append(i)

if case .elective = newline {
total += size
} else {
// `size` is never used in this case, because the break always fires. Use `maxLineLength`
// to ensure enclosing groups are large enough to force preceding breaks to fire.
total += maxLineLength
switch newline {
case .elective, .escaped:
total += size
default:
// `size` is never used in this case, because the break always fires. Use `maxLineLength`
// to ensure enclosing groups are large enough to force preceding breaks to fire.
total += maxLineLength
}

// Space tokens have a length equal to its size.
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftFormat/PrettyPrint/PrettyPrintBuffer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ struct PrettyPrintBuffer {
numberToPrint = min(count, maximumBlankLines + 1) - consecutiveNewlineCount
case .hard(let count):
numberToPrint = count
case .escaped:
numberToPrint = 1
}

guard numberToPrint > 0 else { return }
Expand Down
4 changes: 4 additions & 0 deletions Sources/SwiftFormat/PrettyPrint/Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ enum NewlineBehavior {
/// newlines and the configured maximum number of blank lines.
case hard(count: Int)

/// Break onto a new line is allowed if neccessary. If a line break is emitted, it will be escaped with a '\', and this breaks whitespace will be printed prior to the
/// escaped line break. This is useful in multiline strings where we don't want newlines printed in syntax to appear in the literal.
case escaped

/// An elective newline that respects discretionary newlines from the user-entered text.
static let elective = NewlineBehavior.elective(ignoresDiscretionary: false)

Expand Down
Loading