Skip to content

Commit

Permalink
Escaped characters detection in literals (#231)
Browse files Browse the repository at this point in the history
* Add tests for inline snapshot

* Add more tests
  • Loading branch information
ferranpujolcamins authored and stephencelis committed Jun 18, 2019
1 parent 950c0dc commit dc14bff
Show file tree
Hide file tree
Showing 34 changed files with 1,209 additions and 48 deletions.
224 changes: 224 additions & 0 deletions SnapshotTesting.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

55 changes: 54 additions & 1 deletion Sources/SnapshotTesting/AssertInlineSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ internal struct Context {
let sourceCode: String
let diffable: String
let fileName: String
// First line of a file is line 1 (as with the #line macro)
let lineIndex: Int

func setSourceCode(_ newSourceCode: String) -> Context {
Expand Down Expand Up @@ -216,7 +217,35 @@ internal func writeInlineSnapshot(_ recordings: inout Recordings,
}

/// Find the end of multi-line literal and replace contents with recording.
if let multiLineLiteralEndIndex = sourceCodeLines[offsetStartIndex...].firstIndex(where: { $0.contains(multiLineStringLiteralTerminator) }) {
if let multiLineLiteralEndIndex = sourceCodeLines[offsetStartIndex...].firstIndex(where: { $0.hasClosingMultilineStringDelimiter() }) {

let diffableLines = context.diffable.split(separator: "\n")

// Add #'s to the multiline string literal if needed
let numberSigns: String
if context.diffable.hasEscapedSpecialCharactersLiteral() {
numberSigns = String(repeating: "#", count: context.diffable.numberOfNumberSignsNeeded())
} else if nil != diffableLines.first(where: { $0.endsInBackslash() }) {
// We want to avoid \ being interpreted as an escaped newline in the recorded inline snapshot
numberSigns = "#"
} else {
numberSigns = ""
}
let multiLineStringLiteralTerminatorPre = numberSigns + multiLineStringLiteralTerminator
let multiLineStringLiteralTerminatorPost = multiLineStringLiteralTerminator + numberSigns

// Update opening (#...)"""
sourceCodeLines[functionLineIndex].replaceFirstOccurrence(
of: extendedOpeningStringDelimitersPattern,
with: multiLineStringLiteralTerminatorPre
)

// Update closing """(#...)
sourceCodeLines[multiLineLiteralEndIndex].replaceFirstOccurrence(
of: extendedClosingStringDelimitersPattern,
with: multiLineStringLiteralTerminatorPost
)

/// Convert actual value to Lines to insert
let indentText = indentation(of: sourceCodeLines[multiLineLiteralEndIndex])
let newDiffableLines = context.diffable
Expand Down Expand Up @@ -250,8 +279,32 @@ private func indentation<S: StringProtocol>(of str: S) -> String {
return String(repeating: " ", count: count)
}

fileprivate extension Substring {
mutating func replaceFirstOccurrence(of pattern: String, with newString: String) {
let newString = replacingOccurrences(of: pattern, with: newString, options: .regularExpression)
self = Substring(newString)
}

func hasOpeningMultilineStringDelimiter() -> Bool {
return range(of: extendedOpeningStringDelimitersPattern, options: .regularExpression) != nil
}

func hasClosingMultilineStringDelimiter() -> Bool {
return range(of: extendedClosingStringDelimitersPattern, options: .regularExpression) != nil
}

func endsInBackslash() -> Bool {
if let lastChar = last {
return lastChar == Character(#"\"#)
}
return false
}
}

private let emptyStringLiteralWithCloseBrace = "\"\")"
private let multiLineStringLiteralTerminator = "\"\"\""
private let extendedOpeningStringDelimitersPattern = #"#{0,}\"\"\""#
private let extendedClosingStringDelimitersPattern = ##"\"\"\"#{0,}"##

// When we modify a file, the line numbers reported by the compiler through #line are no longer
// accurate. With the FileRecording values we keep track of we modify the files so we can adjust
Expand Down
57 changes: 57 additions & 0 deletions Sources/SnapshotTesting/Common/String+SpecialCharacters.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

extension String {

/// Checks whether the string has escaped special character literals or not.
///
/// This method won't detect an unescaped special character.
/// For example, this method will return true for "\\n" or #"\n"#, but false for "\n"
///
/// The following are the special character literals that this methods looks for:
/// The escaped special characters \0 (null character), \\ (backslash),
/// \t (horizontal tab), \n (line feed), \r (carriage return),
/// \" (double quotation mark) and \' (single quotation mark),
/// An arbitrary Unicode scalar value, written as \u{n},
/// where n is a 1–8 digit hexadecimal number (Unicode is discussed in Unicode below)
/// The character sequence "#
///
/// - Returns: True if the string has any special character literals, false otherwise.
func hasEscapedSpecialCharactersLiteral() -> Bool {
let multilineLiteralAndNumberSign = ##"""
"""#
"""##
let patterns = [
// Matches \u{n} where n is a 1–8 digit hexadecimal number
try? NSRegularExpression(pattern: #"\\u\{[a-fA-f0-9]{1,8}\}"#, options: .init()),
try? NSRegularExpression(pattern: #"\0"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\\"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\t"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\n"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\r"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\""#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: #"\'"#, options: .ignoreMetacharacters),
try? NSRegularExpression(pattern: multilineLiteralAndNumberSign, options: .ignoreMetacharacters),
]
let matches = patterns.compactMap { $0?.firstMatch(in: self, options: .init(), range: NSRange.init(location: 0, length: self.count)) }
return matches.count > 0
}


/// This method calculates how many number signs (#) we need to add around a string
/// literal to properly escape its content.
///
/// Multiple # are needed when the literal contains "#, "##, "### ...
///
/// - Returns: The number of "number signs(#)" needed around a string literal.
/// When there is no "#, ... return 1
func numberOfNumberSignsNeeded() -> Int {
let pattern = try! NSRegularExpression(pattern: ##""#{1,}"##, options: .init())

let matches = pattern.matches(in: self, options: .init(), range: NSRange.init(location: 0, length: self.count))

// If we have "## then the length of the match is 3,
// which is also the number of "number signs (#)" we need to add
// before and after the string literal
return matches.map { $0.range.length }.max() ?? 1
}
}

0 comments on commit dc14bff

Please sign in to comment.