Skip to content

Commit 20ce3cb

Browse files
authored
Merge pull request #2744 from AppAppWorks/fixit-for-spaces-at-ends-of-bare-regex-literal
add fix-its for a bare regex literal with leading and/or trailing spaces
2 parents 24a2501 + bba7b15 commit 20ce3cb

12 files changed

+704
-187
lines changed

Sources/SwiftParser/Lexer/RegexLiteralLexer.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,6 @@ fileprivate struct RegexLiteralLexer {
207207
lastUnespacedSpaceOrTab.position.advanced(by: 1).pointer == slashBegin.position.pointer
208208
{
209209
if mustBeRegex {
210-
// TODO: We ought to have a fix-it that suggests #/.../#. We could
211-
// suggest escaping, but that would be wrong if the user has written (?x).
212-
// TODO: Should we suggest #/.../# for space-as-first character too?
213210
builder.recordPatternError(.spaceAtEndOfRegexLiteral, at: lastUnespacedSpaceOrTab)
214211
} else {
215212
return .notARegex
@@ -256,7 +253,6 @@ fileprivate struct RegexLiteralLexer {
256253
// }
257254
//
258255
if mustBeRegex {
259-
// TODO: We ought to have a fix-it that inserts a backslash to escape.
260256
builder.recordPatternError(.spaceAtStartOfRegexLiteral, at: cursor)
261257
} else {
262258
return .notARegex

Sources/SwiftParserDiagnostics/LexerDiagnosticMessages.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,93 @@ extension SwiftSyntax.TokenDiagnostic {
325325
changes.append(.replaceLeadingTrivia(token: nextToken, newTrivia: []))
326326
}
327327
return [FixIt(message: .removeExtraneousWhitespace, changes: changes)]
328+
case .spaceAtEndOfRegexLiteral:
329+
if let regexLiteral = token.regexParent {
330+
// wouldn't suggest `insertBackslash` because the potential presence of (?x) somewhere preceding the trailing
331+
// space could mean the trailing space has no semantic meaning. Escaping the space could change the semantics.
332+
return [regexLiteral.convertToExtendedRegexLiteralFixIt]
333+
} else {
334+
return []
335+
}
336+
case .spaceAtStartOfRegexLiteral:
337+
guard let regexLiteral = token.regexParent else {
338+
return []
339+
}
340+
341+
let regexText = regexLiteral.regex.text
342+
let lastIndex = regexText.index(before: regexText.endIndex)
343+
if regexText.startIndex != lastIndex && regexText[lastIndex].isWhitespace {
344+
// if the regex has a distinct trailing space, same as the handling at `case .spaceAtEndOfRegexLiteral`
345+
return [regexLiteral.convertToExtendedRegexLiteralFixIt]
346+
} else {
347+
let escapedRegexText = #"\\#(regexText)"#
348+
return [
349+
regexLiteral.convertToExtendedRegexLiteralFixIt,
350+
FixIt(
351+
message: .insertBackslash,
352+
changes: [
353+
.replace(
354+
oldNode: Syntax(regexLiteral),
355+
newNode: Syntax(
356+
regexLiteral
357+
.with(\.regex, .regexLiteralPattern(escapedRegexText))
358+
)
359+
)
360+
]
361+
),
362+
]
363+
}
328364
default:
329365
return []
330366
}
331367
}
332368
}
369+
370+
private extension TokenSyntax {
371+
var regexParent: RegexLiteralExprSyntax? {
372+
var parent = Syntax(self)
373+
while parent.kind != .regexLiteralExpr, let upper = parent.parent {
374+
parent = upper
375+
}
376+
return parent.as(RegexLiteralExprSyntax.self)
377+
}
378+
}
379+
380+
private extension RegexLiteralExprSyntax {
381+
/// Creates a Fix-it that suggests converting to extended regex literal
382+
///
383+
/// Covers the following cases:
384+
/// ```swift
385+
/// let leadingSpaceRegex = / ,/
386+
/// // converts to
387+
/// let leadingSpaceExtendedRegex = #/ ,/#
388+
///
389+
/// let leadingAndTrailingSpaceRegex = / , /
390+
/// // converts to
391+
/// let leadingAndTrailingSpaceExtendedRegex = #/ , /#
392+
///
393+
/// let trailingSpaceRegex = /, /
394+
/// // converts to
395+
/// let trailingSpaceExtendedRegex = #/ ,/#
396+
///
397+
/// let trailingSpaceMissingClosingSlashRegex = /,
398+
/// // converts to
399+
/// let trailingSpaceExtendedRegex = #/, /#
400+
/// ```
401+
var convertToExtendedRegexLiteralFixIt: FixIt {
402+
FixIt(
403+
message: .convertToExtendedRegexLiteral,
404+
changes: [
405+
.replace(
406+
oldNode: Syntax(self),
407+
newNode: Syntax(
408+
with(\.openingSlash, .regexSlashToken())
409+
.with(\.openingPounds, .regexPoundDelimiter("#", leadingTrivia: leadingTrivia))
410+
.with(\.closingPounds, .regexPoundDelimiter("#", trailingTrivia: trailingTrivia))
411+
.with(\.closingSlash, .regexSlashToken())
412+
)
413+
)
414+
]
415+
)
416+
}
417+
}

Sources/SwiftParserDiagnostics/ParserDiagnosticMessages.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,9 @@ extension FixItMessage where Self == StaticParserFixIt {
655655
public static var insertAttributeArguments: Self {
656656
.init("insert attribute argument")
657657
}
658+
public static var insertBackslash: Self {
659+
.init(#"insert '\'"#)
660+
}
658661
public static var insertNewline: Self {
659662
.init("insert newline")
660663
}
@@ -691,6 +694,9 @@ extension FixItMessage where Self == StaticParserFixIt {
691694
public static var wrapInBackticks: Self {
692695
.init("if this name is unavoidable, use backticks to escape it")
693696
}
697+
public static var convertToExtendedRegexLiteral: Self {
698+
.init("convert to extended regex literal with '#'")
699+
}
694700
}
695701

696702
public struct InsertFixIt: ParserFixIt {

Tests/SwiftParserTest/Assertions.swift

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,9 @@ struct DiagnosticSpec {
289289
}
290290

291291
/// Assert that `location` is the same as that of `locationMarker` in `tree`.
292-
func assertLocation<T: SyntaxProtocol>(
292+
func assertLocation(
293293
_ location: SourceLocation,
294-
in tree: T,
294+
in tree: some SyntaxProtocol,
295295
markerLocations: [String: Int],
296296
expectedLocationMarker locationMarker: String,
297297
file: StaticString = #filePath,
@@ -315,9 +315,9 @@ func assertLocation<T: SyntaxProtocol>(
315315

316316
/// Assert that the diagnostic `note` produced in `tree` matches `spec`,
317317
/// using `markerLocations` to translate markers to actual source locations.
318-
func assertNote<T: SyntaxProtocol>(
318+
func assertNote(
319319
_ note: Note,
320-
in tree: T,
320+
in tree: some SyntaxProtocol,
321321
markerLocations: [String: Int],
322322
expected spec: NoteSpec
323323
) {
@@ -335,9 +335,9 @@ func assertNote<T: SyntaxProtocol>(
335335

336336
/// Assert that the diagnostic `diag` produced in `tree` matches `spec`,
337337
/// using `markerLocations` to translate markers to actual source locations.
338-
func assertDiagnostic<T: SyntaxProtocol>(
338+
func assertDiagnostic(
339339
_ diag: Diagnostic,
340-
in tree: T,
340+
in tree: some SyntaxProtocol,
341341
markerLocations: [String: Int],
342342
expected spec: DiagnosticSpec
343343
) {
@@ -491,9 +491,9 @@ public struct AssertParseOptions: OptionSet, Sendable {
491491
extension ParserTestCase {
492492
/// After a test case has been mutated, assert that the mutated source
493493
/// round-trips and doesn’t hit any assertion failures in the parser.
494-
fileprivate static func assertMutationRoundTrip<S: SyntaxProtocol>(
494+
fileprivate static func assertMutationRoundTrip(
495495
source: [UInt8],
496-
_ parse: (inout Parser) -> S,
496+
_ parse: (inout Parser) -> some SyntaxProtocol,
497497
swiftVersion: Parser.SwiftVersion?,
498498
experimentalFeatures: Parser.ExperimentalFeatures,
499499
file: StaticString,
@@ -524,14 +524,84 @@ extension ParserTestCase {
524524
}
525525
}
526526

527+
enum FixItsApplication {
528+
/// Apply only the fix-its whose messages are included in `applyFixIts` to generate `fixedSource`.
529+
case optIn(applyFixIts: [String], fixedSource: String)
530+
/// Apply all fix-its to generate `fixedSource`.
531+
case all(fixedSource: String)
532+
533+
init?(applyFixIts: [String]?, expectedFixedSource: String?) {
534+
if let applyFixIts {
535+
self = .optIn(applyFixIts: applyFixIts, fixedSource: expectedFixedSource ?? "")
536+
} else if let expectedFixedSource {
537+
self = .all(fixedSource: expectedFixedSource)
538+
} else {
539+
return nil
540+
}
541+
}
542+
543+
var applyFixIts: [String]? {
544+
switch self {
545+
case .optIn(let applyFixIts, _):
546+
return applyFixIts
547+
case .all:
548+
return nil
549+
}
550+
}
551+
552+
var expectedFixedSource: String {
553+
switch self {
554+
case .optIn(_, let fixedSource), .all(let fixedSource):
555+
return fixedSource
556+
}
557+
}
558+
}
559+
560+
/// Convenient version of `assertParse` that allows checking a single configuration of applied Fix-Its.
561+
///
562+
/// - Parameters:
563+
/// - applyFixIts: Applies only the fix-its with these messages. Nil means applying all fix-its.
564+
/// - expectedFixedSource: Asserts that the source after applying fix-its matches
565+
/// this string.
566+
func assertParse(
567+
_ markedSource: String,
568+
_ parse: @Sendable (inout Parser) -> some SyntaxProtocol = { SourceFileSyntax.parse(from: &$0) },
569+
substructure expectedSubstructure: (some SyntaxProtocol)? = Optional<Syntax>.none,
570+
substructureAfterMarker: String = "START",
571+
diagnostics expectedDiagnostics: [DiagnosticSpec] = [],
572+
applyFixIts: [String]? = nil,
573+
fixedSource expectedFixedSource: String? = nil,
574+
options: AssertParseOptions = [],
575+
swiftVersion: Parser.SwiftVersion? = nil,
576+
experimentalFeatures: Parser.ExperimentalFeatures? = nil,
577+
file: StaticString = #filePath,
578+
line: UInt = #line
579+
) {
580+
assertParse(
581+
markedSource,
582+
parse,
583+
substructure: expectedSubstructure,
584+
substructureAfterMarker: substructureAfterMarker,
585+
diagnostics: expectedDiagnostics,
586+
fixItsApplications: FixItsApplication(applyFixIts: applyFixIts, expectedFixedSource: expectedFixedSource).map {
587+
[$0]
588+
} ?? [],
589+
options: options,
590+
swiftVersion: swiftVersion,
591+
experimentalFeatures: experimentalFeatures,
592+
file: file,
593+
line: line
594+
)
595+
}
596+
527597
/// Verifies that parsing of `markedSource` produces expected results using a
528598
/// combination of various testing techniques:
529599
///
530600
/// 1. Asserts that parsing of `markedSource` round-trips, ie. that the printed
531601
/// parsed tree is the same as the input.
532602
/// 2. Checks that parsing produces the expected list of diagnostics. If no
533603
/// diagnostics are passed, asserts that the input parses without any errors.
534-
/// 3. Checks that applying all Fix-Its of the source code results in the
604+
/// 3. Checks that applying fix-its of the source code results in the
535605
/// expected fixed source, effectively testing the Fix-Its.
536606
/// 4. If a substructure is passed, asserts that the parsed tree contains a
537607
/// subtree of that structure.
@@ -558,21 +628,19 @@ extension ParserTestCase {
558628
/// - expectedDiagnostics: Asserts the given diagnostics were output, by default it
559629
/// asserts the parse was successful (ie. it has no diagnostics). Note
560630
/// that `DiagnosticsSpec` uses the location marked by `1️⃣` by default.
561-
/// - applyFixIts: Applies only the fix-its with these messages.
562-
/// - expectedFixedSource: Asserts that the source after applying fix-its matches
563-
/// this string.
631+
/// - fixItsApplications: A list of `FixItsApplication` to check for whether applying certain fix-its to the source
632+
/// will generate a certain expected fixed source. An empty list means no fix-its are expected.
564633
/// - swiftVersion: The version of Swift using which the file should be parsed.
565634
/// Defaults to the latest version.
566635
/// - experimentalFeatures: A list of experimental features to enable, or
567636
/// `nil` to enable the default set of features provided by the test case.
568-
func assertParse<S: SyntaxProtocol>(
637+
func assertParse(
569638
_ markedSource: String,
570-
_ parse: @Sendable (inout Parser) -> S = { SourceFileSyntax.parse(from: &$0) },
639+
_ parse: @Sendable (inout Parser) -> some SyntaxProtocol = { SourceFileSyntax.parse(from: &$0) },
571640
substructure expectedSubstructure: (some SyntaxProtocol)? = Optional<Syntax>.none,
572641
substructureAfterMarker: String = "START",
573642
diagnostics expectedDiagnostics: [DiagnosticSpec] = [],
574-
applyFixIts: [String]? = nil,
575-
fixedSource expectedFixedSource: String? = nil,
643+
fixItsApplications: [FixItsApplication] = [],
576644
options: AssertParseOptions = [],
577645
swiftVersion: Parser.SwiftVersion? = nil,
578646
experimentalFeatures: Parser.ExperimentalFeatures? = nil,
@@ -591,7 +659,7 @@ extension ParserTestCase {
591659
parser.enableAlternativeTokenChoices()
592660
}
593661
#endif
594-
let tree: S = parse(&parser)
662+
let tree = parse(&parser)
595663

596664
// Round-trip
597665
assertStringsEqualWithDiff(
@@ -640,27 +708,30 @@ extension ParserTestCase {
640708
}
641709

642710
// Applying Fix-Its
643-
if expectedDiagnostics.contains(where: { !$0.fixIts.isEmpty }) && expectedFixedSource == nil {
711+
if expectedDiagnostics.contains(where: { !$0.fixIts.isEmpty }) && fixItsApplications.isEmpty {
644712
XCTFail("Expected a fixed source if the test case produces diagnostics with Fix-Its", file: file, line: line)
645-
} else if let expectedFixedSource = expectedFixedSource {
646-
let fixedTree = FixItApplier.applyFixes(from: diags, filterByMessages: applyFixIts, to: tree)
647-
var fixedTreeDescription = fixedTree.description
648-
if options.contains(.normalizeNewlinesInFixedSource) {
649-
fixedTreeDescription =
650-
fixedTreeDescription
651-
.replacingOccurrences(of: "\r\n", with: "\n")
652-
.replacingOccurrences(of: "\r", with: "\n")
713+
} else {
714+
for fixItsApplication in fixItsApplications {
715+
let applyFixIts = fixItsApplication.applyFixIts
716+
let fixedTree = FixItApplier.applyFixes(from: diags, filterByMessages: applyFixIts, to: tree)
717+
var fixedTreeDescription = fixedTree.description
718+
if options.contains(.normalizeNewlinesInFixedSource) {
719+
fixedTreeDescription =
720+
fixedTreeDescription
721+
.replacingOccurrences(of: "\r\n", with: "\n")
722+
.replacingOccurrences(of: "\r", with: "\n")
723+
}
724+
assertStringsEqualWithDiff(
725+
fixedTreeDescription.trimmingTrailingWhitespace(),
726+
fixItsApplication.expectedFixedSource.trimmingTrailingWhitespace(),
727+
"Applying \(applyFixIts?.description ?? "all Fix-Its") didn’t produce the expected fixed source",
728+
file: file,
729+
line: line
730+
)
653731
}
654-
assertStringsEqualWithDiff(
655-
fixedTreeDescription.trimmingTrailingWhitespace(),
656-
expectedFixedSource.trimmingTrailingWhitespace(),
657-
"Applying all Fix-Its didn’t produce the expected fixed source",
658-
file: file,
659-
line: line
660-
)
661732
}
662733

663-
if expectedDiagnostics.allSatisfy({ $0.fixIts.isEmpty }) && expectedFixedSource != nil {
734+
if expectedDiagnostics.allSatisfy({ $0.fixIts.isEmpty }) && !fixItsApplications.isEmpty {
664735
XCTFail(
665736
"Fixed source was provided but the test case produces no diagnostics with Fix-Its",
666737
file: file,
@@ -747,9 +818,9 @@ class TriviaRemover: SyntaxRewriter {
747818
}
748819
}
749820

750-
func assertBasicFormat<S: SyntaxProtocol>(
821+
func assertBasicFormat(
751822
source: String,
752-
parse: (inout Parser) -> S,
823+
parse: (inout Parser) -> some SyntaxProtocol,
753824
swiftVersion: Parser.SwiftVersion?,
754825
experimentalFeatures: Parser.ExperimentalFeatures,
755826
file: StaticString = #filePath,

0 commit comments

Comments
 (0)