mirror of
https://github.com/apple/swift.git
synced 2025-12-14 20:36:38 +01:00
[oslog] [stdlib-private] Add a prototype of the new os_log swift APIs
that accept string interpolations. The prototypes are added to stdlib/private directory and will be used only in tests and in experimental code.
This commit is contained in:
@@ -30,8 +30,8 @@ Enumerators:
|
||||
SwiftPrivate: true
|
||||
Functions:
|
||||
- Name: _os_log_impl
|
||||
Availability: nonswift
|
||||
AvailabilityMsg: 'Use os_log'
|
||||
SwiftPrivate: true
|
||||
NullabilityOfRet: O
|
||||
- Name: _os_log_error_impl
|
||||
Availability: nonswift
|
||||
AvailabilityMsg: 'Use os_log'
|
||||
|
||||
@@ -16,6 +16,8 @@ if(SWIFT_BUILD_SDK_OVERLAY)
|
||||
# the correct order for Windows.
|
||||
add_subdirectory(StdlibUnittest)
|
||||
|
||||
add_subdirectory(OSLog)
|
||||
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
|
||||
add_subdirectory(StdlibUnittestFoundationExtras)
|
||||
if (SWIFT_INCLUDE_TESTS)
|
||||
|
||||
14
stdlib/private/OSLog/CMakeLists.txt
Normal file
14
stdlib/private/OSLog/CMakeLists.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
add_swift_target_library(swiftOSLogPrototype
|
||||
IS_SDK_OVERLAY
|
||||
SHARED
|
||||
TARGET_LIBRARY
|
||||
|
||||
OSLog.swift
|
||||
OSLogMessage.swift
|
||||
|
||||
SWIFT_MODULE_DEPENDS_IOS Darwin os
|
||||
SWIFT_MODULE_DEPENDS_OSX Darwin os
|
||||
SWIFT_MODULE_DEPENDS_TVOS Darwin os
|
||||
SWIFT_MODULE_DEPENDS_WATCHOS Darwin os
|
||||
TARGET_SDKS ALL_APPLE_PLATFORMS
|
||||
INSTALL_IN_COMPONENT never_install)
|
||||
69
stdlib/private/OSLog/OSLog.swift
Normal file
69
stdlib/private/OSLog/OSLog.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//===----------------- OSLog.swift ----------------------------------------===//
|
||||
//
|
||||
// This source file is part of the Swift.org open source project
|
||||
//
|
||||
// Copyright (c) 2014 - 2019 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
|
||||
//
|
||||
//===-----------------------------------------------------------------------===//
|
||||
|
||||
// This file contains the new swift APIs for OS log that accept string
|
||||
// interpolations. This is a prototype meant for experimentation and testing.
|
||||
// Do not use it outside of tests.
|
||||
|
||||
@_exported import os
|
||||
|
||||
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
|
||||
public struct Logger {
|
||||
internal let logObject: OSLog
|
||||
|
||||
/// Create a custom OS log object.
|
||||
public init(subsystem: String, category: String) {
|
||||
logObject = OSLog(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
/// Return the default OS log object.
|
||||
public init() {
|
||||
logObject = OSLog.default
|
||||
}
|
||||
|
||||
/// Log a string interpolation at a given level. The level is `default` if
|
||||
/// it is not specified.
|
||||
public func log(level: OSLogType = .default, _ message: OSLogMessage) {
|
||||
osLog(logObject, level, message)
|
||||
}
|
||||
|
||||
// TODO: define overloads for logging at specific levels: debug, info, notice,
|
||||
// error, fault based on the Swift forum "logging-levels" discussion.
|
||||
}
|
||||
|
||||
/// Given an instance of the custom string interpolation type: `OSLogMessage`,
|
||||
/// extract the format string, serialize the arguments to a byte buffer,
|
||||
/// and pass them to the OS logging system.
|
||||
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
|
||||
internal func osLog(
|
||||
_ logObject: OSLog,
|
||||
_ logLevel: OSLogType,
|
||||
_ message: OSLogMessage
|
||||
) {
|
||||
guard logObject.isEnabled(type: logLevel) else { return }
|
||||
|
||||
let bufferSize = message.bufferSize
|
||||
let bufferMemory = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
var builder = OSLogByteBufferBuilder(bufferMemory)
|
||||
|
||||
message.serializeArguments(into: &builder)
|
||||
|
||||
message.formatString.withCString { cFormatString in
|
||||
___os_log_impl(UnsafeMutableRawPointer(mutating: #dsohandle),
|
||||
logObject,
|
||||
logLevel,
|
||||
cFormatString,
|
||||
bufferMemory,
|
||||
UInt32(bufferSize))
|
||||
}
|
||||
bufferMemory.deallocate()
|
||||
}
|
||||
367
stdlib/private/OSLog/OSLogMessage.swift
Normal file
367
stdlib/private/OSLog/OSLogMessage.swift
Normal file
@@ -0,0 +1,367 @@
|
||||
//===----------------- OSLogMessage.swift ---------------------------------===//
|
||||
//
|
||||
// This source file is part of the Swift.org open source project
|
||||
//
|
||||
// Copyright (c) 2014 - 2019 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
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// This file contains data structures and helper functions that are used by
|
||||
// the new OS log APIs. These are prototype implementations and should not be
|
||||
// used outside of tests.
|
||||
|
||||
/// Formatting options supported by the logging APIs for logging integers.
|
||||
/// These can be specified in the string interpolation passed to the log APIs.
|
||||
/// For Example,
|
||||
/// log.info("Writing to file with permissions: \(perm, format: .octal)")
|
||||
///
|
||||
/// See `OSLogInterpolation.appendInterpolation` definitions for default options
|
||||
/// for integer types.
|
||||
public enum IntFormat {
|
||||
case decimal
|
||||
case hex
|
||||
case octal
|
||||
}
|
||||
|
||||
/// Privacy qualifiers for indicating the privacy level of the logged data
|
||||
/// to the logging system. These can be specified in the string interpolation
|
||||
/// passed to the log APIs.
|
||||
/// For Example,
|
||||
/// log.info("Login request from user id \(userid, privacy: .private)")
|
||||
///
|
||||
/// See `OSLogInterpolation.appendInterpolation` definitions for default options
|
||||
/// for each supported type.
|
||||
public enum Privacy {
|
||||
case `private`
|
||||
case `public`
|
||||
}
|
||||
|
||||
/// Maximum number of arguments i.e., interpolated expressions that can
|
||||
/// be used in the string interpolations passed to the log APIs.
|
||||
/// This limit is imposed by the ABI of os_log.
|
||||
public var maxOSLogArgumentCount: Int {
|
||||
return 48
|
||||
}
|
||||
|
||||
internal var bitsPerByte: Int {
|
||||
return 8
|
||||
}
|
||||
|
||||
/// Represents a string interpolation passed to the log APIs.
|
||||
///
|
||||
/// This type converts (through its methods) the given string interpolation into
|
||||
/// a C-style format string and a sequence of arguments, which is represented
|
||||
/// by the type `OSLogArguments`.
|
||||
///
|
||||
/// Do not create an instance of this type directly. It is used by the compiler
|
||||
/// when you pass a string interpolation to the log APIs.
|
||||
/// Extend this type with more `appendInterpolation` overloads to enable
|
||||
/// interpolating additional types.
|
||||
public struct OSLogInterpolation : StringInterpolationProtocol {
|
||||
/// A format string constructed from the given string interpolation to be
|
||||
/// passed to the os_log ABI.
|
||||
internal var formatString: String
|
||||
|
||||
/// A representation of a sequence of arguments that must be serialized
|
||||
/// to a byte buffer and passed to the os_log ABI. Each argument, which is
|
||||
/// an (autoclosured) expressions that is interpolated, is prepended with a
|
||||
/// two byte header. The first header byte consists of a four bit flag and
|
||||
/// a four bit type. The second header byte has the size of the argument in
|
||||
/// bytes. This is schematically illustrated below.
|
||||
/// ----------------------------
|
||||
/// | 4-bit type | 4-bit flag |
|
||||
/// ----------------------------
|
||||
/// | 1st argument size in bytes|
|
||||
/// ----------------------------
|
||||
/// | 1st argument bytes |
|
||||
/// ----------------------------
|
||||
/// | 4-bit type | 4-bit flag |
|
||||
/// -----------------------------
|
||||
/// | 2nd argument size in bytes|
|
||||
/// ----------------------------
|
||||
/// | 2nd argument bytes |
|
||||
/// ----------------------------
|
||||
/// ...
|
||||
internal var arguments: OSLogArguments
|
||||
|
||||
/// The possible values for the argument flag, as defined by the os_log ABI,
|
||||
/// which occupies four least significant bits of the first byte of the
|
||||
/// argument header. The first two bits are used to indicate privacy and
|
||||
/// the other two are reserved.
|
||||
internal enum ArgumentFlag: UInt8 {
|
||||
case privateFlag = 0x1
|
||||
case publicFlag = 0x2
|
||||
}
|
||||
|
||||
/// The possible values for the argument type, as defined by the os_log ABI,
|
||||
/// which occupies four most significant bits of the first byte of the
|
||||
/// argument header.
|
||||
internal enum ArgumentType: UInt8 {
|
||||
case scalar = 0
|
||||
// TODO: more types will be added here.
|
||||
}
|
||||
|
||||
/// The first summary byte in the byte buffer passed to the os_log ABI that
|
||||
/// summarizes the privacy and nature of the arguments.
|
||||
internal var preamble: UInt8
|
||||
|
||||
/// Bit mask for setting bits in the peamble. The bits denoted by the bit
|
||||
/// mask indicate whether there is an argument that is private, and whether
|
||||
/// there is an argument that is non-scalar: String, NSObject or Pointer.
|
||||
internal enum PreambleBitMask: UInt8 {
|
||||
case privateBitMask = 0x1
|
||||
case nonScalarBitMask = 0x2
|
||||
}
|
||||
|
||||
/// The second summary byte that denotes the number of arguments, which is
|
||||
/// also the number of interpolated expressions. This will be determined
|
||||
/// on the fly in order to support concatenation and interpolation of
|
||||
/// instances of `OSLogMessage`.
|
||||
internal var argumentCount: UInt8
|
||||
|
||||
public init(literalCapacity: Int, interpolationCount: Int) {
|
||||
// TODO: format string must be fully constructed at compile time.
|
||||
// The parameters `literalCapacity` and `interpolationCount` are ignored.
|
||||
formatString = ""
|
||||
arguments = OSLogArguments()
|
||||
preamble = 0
|
||||
argumentCount = 0
|
||||
}
|
||||
|
||||
public mutating func appendLiteral(_ literal: String) {
|
||||
formatString += literal.percentEscapedString
|
||||
}
|
||||
|
||||
/// Define interpolation for expressions of type Int. This definition enables
|
||||
/// passing a formatting option and a privacy qualifier along with the
|
||||
/// interpolated expression as shown below:
|
||||
///
|
||||
/// "\(x, format: .hex, privacy: .private\)"
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - number: the interpolated expression of type Int, which is autoclosured.
|
||||
/// - format: a formatting option available for Int types, defined by the
|
||||
/// enum `IntFormat`.
|
||||
/// - privacy: a privacy qualifier which is either private or public.
|
||||
/// The default is public.
|
||||
public mutating func appendInterpolation(
|
||||
_ number: @autoclosure @escaping () -> Int,
|
||||
format: IntFormat = .decimal,
|
||||
privacy: Privacy = .public
|
||||
) {
|
||||
guard argumentCount < maxOSLogArgumentCount else { return }
|
||||
|
||||
addIntHeadersAndFormatSpecifier(
|
||||
format,
|
||||
isPrivate: privacy == .private,
|
||||
bitWidth: Int.bitWidth,
|
||||
isSigned: true)
|
||||
arguments.append(number)
|
||||
}
|
||||
|
||||
/// Construct/update format string and headers from the qualifiers (of the
|
||||
/// interpolated expression) passed as parameters.
|
||||
///
|
||||
/// All arguments to this function must be known at compile time.
|
||||
public mutating func addIntHeadersAndFormatSpecifier(
|
||||
_ format: IntFormat,
|
||||
isPrivate: Bool,
|
||||
bitWidth: Int,
|
||||
isSigned: Bool
|
||||
) {
|
||||
formatString += getIntegerFormatSpecifier(
|
||||
format,
|
||||
isPrivate: isPrivate,
|
||||
bitWidth: bitWidth,
|
||||
isSigned: isSigned)
|
||||
|
||||
addArgumentHeaders(
|
||||
flag: isPrivate ? .privateFlag : .publicFlag,
|
||||
type: .scalar,
|
||||
size: UInt8(bitWidth / bitsPerByte))
|
||||
|
||||
updateSummaryBytes(isPrivate: isPrivate)
|
||||
}
|
||||
|
||||
/// Set the private bit of the preamble if the `isPrivate` parameter is true
|
||||
/// and increment the argument count. Note that the private bit in the
|
||||
/// preamable is set if any of the arguments is private.
|
||||
internal mutating func updateSummaryBytes(isPrivate: Bool) {
|
||||
if (isPrivate) {
|
||||
preamble |= PreambleBitMask.privateBitMask.rawValue
|
||||
}
|
||||
argumentCount += 1
|
||||
}
|
||||
|
||||
/// Append the given argument headers and size.
|
||||
internal mutating func addArgumentHeaders(
|
||||
flag: ArgumentFlag,
|
||||
type: ArgumentType,
|
||||
size: UInt8
|
||||
) {
|
||||
// Flag and type take up one byte where the least significant four bits
|
||||
// is flag and most significant four bits is the type.
|
||||
let flagAndType: UInt8 = (type.rawValue << 4) | flag.rawValue
|
||||
arguments.append(flagAndType)
|
||||
arguments.append(size)
|
||||
}
|
||||
|
||||
/// Construct an os_log format specifier from the given parameters.
|
||||
/// All arguments to this function must be known at compile time.
|
||||
internal func getIntegerFormatSpecifier(
|
||||
_ format: IntFormat,
|
||||
isPrivate: Bool,
|
||||
bitWidth: Int,
|
||||
isSigned: Bool
|
||||
) -> String {
|
||||
var formatSpecifier: String = isPrivate ? "%{private}" : "%{public}"
|
||||
|
||||
// Add a length modifier, if needed, to the specifier
|
||||
// TODO: more length modifiers will be added.
|
||||
if (bitWidth == CLongLong.bitWidth) {
|
||||
formatSpecifier += "ll"
|
||||
}
|
||||
|
||||
// TODO: more format specifiers will be added.
|
||||
switch (format) {
|
||||
case .hex:
|
||||
formatSpecifier += "x"
|
||||
case .octal:
|
||||
formatSpecifier += "o"
|
||||
default:
|
||||
formatSpecifier += isSigned ? "d" : "u"
|
||||
}
|
||||
return formatSpecifier
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// Replace all percents "%" in the string by "%%" so that the string can be
|
||||
/// interpreted as a C format string.
|
||||
public var percentEscapedString: String {
|
||||
get {
|
||||
return self
|
||||
.split(separator: "%", omittingEmptySubsequences: false)
|
||||
.joined(separator: "%%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct OSLogMessage :
|
||||
ExpressibleByStringInterpolation, ExpressibleByStringLiteral
|
||||
{
|
||||
public let interpolation: OSLogInterpolation
|
||||
|
||||
/// Initializer for accepting string interpolations.
|
||||
public init(stringInterpolation: OSLogInterpolation) {
|
||||
interpolation = stringInterpolation
|
||||
}
|
||||
|
||||
/// Initializer for accepting string literals.
|
||||
public init(stringLiteral value: String) {
|
||||
// Note that the actual value of `literalCapacity` is not important as it
|
||||
// is ignored by `OSLogInterpolation.init`. However, it must be a literal.
|
||||
var s = OSLogInterpolation(literalCapacity: 1, interpolationCount: 0)
|
||||
s.appendLiteral(value)
|
||||
self.init(stringInterpolation: s)
|
||||
}
|
||||
|
||||
/// Format string constructed from the string interpolation.
|
||||
public var formatString: String {
|
||||
get { return interpolation.formatString }
|
||||
}
|
||||
|
||||
/// The byte size of the buffer that will passed to the C os_log ABI.
|
||||
/// It will contain the elements of interpolation.arguments and the two
|
||||
/// summary bytes: preamble and argument count.
|
||||
public var bufferSize: Int {
|
||||
get { return interpolation.arguments.byteCount + 2 }
|
||||
}
|
||||
|
||||
/// Serialize the summary bytes and arguments into the given byte-buffer
|
||||
/// builder. The summary bytes are serailized first followed by the arguments.
|
||||
internal func serializeArguments(
|
||||
into bufferBuilder: inout OSLogByteBufferBuilder
|
||||
) {
|
||||
bufferBuilder.serialize(interpolation.preamble)
|
||||
bufferBuilder.serialize(interpolation.argumentCount)
|
||||
interpolation.arguments.serialize(into: &bufferBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
/// A representation of a sequence of arguments and headers (of possibly
|
||||
/// different types) that have to be serialized to a byte buffer. The arguments
|
||||
/// are captured within closures and stored in an array. The closures accept an
|
||||
/// instance of `OSLogByteBufferBuilder`, and when invoked, serialize the
|
||||
/// argument using the passed `OSLogByteBufferBuilder` instance.
|
||||
internal struct OSLogArguments {
|
||||
/// An array of closures that captures arguments of possibly different types.
|
||||
internal var argumentClosures: [(inout OSLogByteBufferBuilder) -> ()]
|
||||
/// Sum total of the byte size of the arguments that are tracked.
|
||||
internal var byteCount: Int
|
||||
|
||||
internal init() {
|
||||
argumentClosures = []
|
||||
byteCount = 0
|
||||
}
|
||||
|
||||
/// Append a byte-sized header, constructed by
|
||||
/// `OSLogMessage.appendInterpolation`, to the tracked array of closures.
|
||||
internal mutating func append(_ header: UInt8) {
|
||||
argumentClosures.append({ $0.serialize(header) })
|
||||
byteCount += OSLogByteBufferBuilder.sizeForEncoding(UInt8.self)
|
||||
}
|
||||
|
||||
/// Append an (autoclosured) interpolated expression of type Int, passed to
|
||||
/// `OSLogMessage.appendInterpolation`, to the tracked array of closures.
|
||||
internal mutating func append(_ value: @escaping () -> Int) {
|
||||
argumentClosures.append({ $0.serialize(value()) })
|
||||
byteCount += OSLogByteBufferBuilder.sizeForEncoding(Int.self)
|
||||
}
|
||||
|
||||
internal func serialize(into bufferBuilder: inout OSLogByteBufferBuilder) {
|
||||
argumentClosures.forEach { $0(&bufferBuilder) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct that manages serialization of instances of specific types to a
|
||||
/// byte buffer. The byte buffer is provided as an argument to the initializer
|
||||
/// so that its lifetime can be managed by the caller.
|
||||
internal struct OSLogByteBufferBuilder {
|
||||
internal var position: UnsafeMutablePointer<UInt8>
|
||||
|
||||
/// Initializer that accepts a pointer to a preexisting buffer.
|
||||
/// - Parameter bufferStart: the starting pointer to a byte buffer
|
||||
/// that must contain the serialized bytes.
|
||||
internal init(_ bufferStart: UnsafeMutablePointer<UInt8>) {
|
||||
position = bufferStart
|
||||
}
|
||||
|
||||
/// Serialize a UInt8 value at the buffer location pointed to by `position`.
|
||||
internal mutating func serialize(_ value: UInt8) {
|
||||
position[0] = value
|
||||
position += 1
|
||||
}
|
||||
|
||||
/// Serialize an Int at the buffer location pointed to by `position`.
|
||||
internal mutating func serialize(_ value: Int) {
|
||||
let byteCount = OSLogByteBufferBuilder.sizeForEncoding(Int.self)
|
||||
let dest = UnsafeMutableRawBufferPointer(start: position, count: byteCount)
|
||||
withUnsafeBytes(of: value) { dest.copyMemory(from: $0) }
|
||||
position += byteCount
|
||||
}
|
||||
|
||||
/// Return the number of bytes needed for serializing an UInt8 value.
|
||||
internal static func sizeForEncoding(_ type: UInt8.Type) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
/// Return the number of bytes needed for serializing an Int value.
|
||||
internal static func sizeForEncoding(_ type: Int.Type) -> Int {
|
||||
return Int.bitWidth / bitsPerByte
|
||||
}
|
||||
}
|
||||
69
test/stdlib/OSLogPrototypeExecTest.swift
Normal file
69
test/stdlib/OSLogPrototypeExecTest.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
// RUN: %empty-directory(%t)
|
||||
// RUN: %target-build-swift %s -swift-version 5 -DPTR_SIZE_%target-ptrsize -o %t/OSLogPrototypeExecTest
|
||||
// RUN: %target-run %t/OSLogPrototypeExecTest
|
||||
// REQUIRES: executable_test
|
||||
// REQUIRES: OS=macosx || OS=ios || OS=tvos || OS=watchos
|
||||
|
||||
// Run-time tests for testing the new OS log APIs that accept string
|
||||
// interpolations. The new APIs are still prototypes and must be used only in
|
||||
// tests.
|
||||
|
||||
import OSLogPrototype
|
||||
import StdlibUnittest
|
||||
|
||||
defer { runAllTests() }
|
||||
|
||||
var OSLogTestSuite = TestSuite("OSLogTest")
|
||||
|
||||
if #available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
|
||||
|
||||
// Following tests check whether valid log calls execute without
|
||||
// compile-time and run-time errors.
|
||||
|
||||
func logMessages(_ h: Logger) {
|
||||
// Test logging of simple messages.
|
||||
h.log("A message with no data")
|
||||
|
||||
// Test logging at specific levels.
|
||||
h.log(level: .debug, "Minimum integer value: \(Int.min, format: .hex)")
|
||||
h.log(level: .info, "Maximum integer value: \(Int.max, format: .hex)")
|
||||
|
||||
let privateID = 0x79abcdef
|
||||
h.log(
|
||||
level: .error,
|
||||
"Private Identifier: \(privateID, format: .hex, privacy: .private)")
|
||||
let addr = 0x7afebabe
|
||||
h.log(
|
||||
level: .fault,
|
||||
"Invalid address: 0x\(addr, format: .hex, privacy: .public)")
|
||||
|
||||
// Test logging with multiple arguments.
|
||||
let filePermissions = 0o777
|
||||
let pid = 122225
|
||||
h.log(
|
||||
level: .error,
|
||||
"""
|
||||
Access prevented: process \(pid) initiated by \
|
||||
user: \(privateID, privacy: .private) attempted resetting \
|
||||
permissions to \(filePermissions, format: .octal)
|
||||
""")
|
||||
}
|
||||
|
||||
OSLogTestSuite.test("log with default logger") {
|
||||
let h = Logger()
|
||||
logMessages(h)
|
||||
}
|
||||
|
||||
OSLogTestSuite.test("log with custom logger") {
|
||||
let h =
|
||||
Logger(subsystem: "com.swift.test", category: "OSLogAPIPrototypeTest")
|
||||
logMessages(h)
|
||||
}
|
||||
|
||||
OSLogTestSuite.test("escaping of percents") {
|
||||
let h = Logger()
|
||||
h.log("a = c % d")
|
||||
h.log("Process failed after 99% completion")
|
||||
h.log("Double percents: %%")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user