Skip to content
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

Escaped characters detection in literals #231

Merged
merged 2 commits into from
Jun 18, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}
}