wickwirew / Runtime

A Swift Runtime library for viewing type info, and the dynamic getting and setting of properties.
MIT License
1.08k stars 94 forks source link

can get/set properties via keypath? #107

Open alexlee002 opened 2 years ago

alexlee002 commented 2 years ago

How can I do that?

wickwirew commented 2 years ago

What do you mean? Using the KeyPath type?

alexlee002 commented 2 years ago

e.g.:

let info = try typeInfo(of: User.self)
let property = try info.property(named: \.userName)
wickwirew commented 2 years ago

There's not a way builtin to the library. But you could just get the offset of the KeyPath and find the property that has the same offset.

let offset = MemoryLayout<Foo>.offset(of: \.bar)
let property = info.properties.first{ $0.offset == offset }
alexlee002 commented 2 years ago

OK, I'll try it ASAP, Thanks!

yonaskolb commented 1 year ago

Hi, I found this issue as I'm trying to get the property name from a keyPath. Using the offset above works for a normal keyPath (\.bar), but not a nested one (\.bar.foo). Ideally in this case I'd like to return the string "bar.foo". @wickwirew any tips?

yonaskolb commented 1 year ago

This seems to do the trick as a brute force approach, though has to traverse the whole tree:

import Runtime

extension KeyPath {

    var propertyName: String? {

        guard let offset = MemoryLayout<Root>.offset(of: self) else {
            return nil
        }
        guard let info = try? typeInfo(of: Root.self) else {
            return nil
        }

        func getPropertyName(for info: TypeInfo, path: [String]) -> String? {
            if let property = info.properties.first(where: { $0.offset == offset }) {
                return (path + [property.name]).joined(separator: ".")
            } else {
                for property in info.properties {
                    if let info = try? typeInfo(of: property.type),
                       let propertyName = getPropertyName(for: info, path: path + [property.name]) {
                        return propertyName
                    }
                }
                return nil
            }
        }
        return getPropertyName(for: info, path: [])
    }
}
wickwirew commented 1 year ago

There really isn't a great way to do this as far as I'm aware. The solution above doesn't work for me for a few cases. The offset when it is in a child object will be relative to it's offset in the parent.

This may work better:

extension KeyPath {

    var propertyName: String? {
        guard let offset = MemoryLayout<Root>.offset(of: self) else {
            return nil
        }
        guard let info = try? typeInfo(of: Root.self) else {
            return nil
        }

        func getPropertyName(for info: TypeInfo, baseOffset: Int, path: [String]) -> String? {
            for property in info.properties {
                // Make sure to check the type as well as the offset. In the case of
                // something like \Foo.bar.baz, if baz is the first property of bar, they
                // will have the same offset since it will be at the top (offset 0).
                if property.offset == offset - baseOffset && property.type == Value.self {
                    return (path + [property.name]).joined(separator: ".")
                }

                guard let propertyTypeInfo = try? typeInfo(of: property.type) else { continue }

                let trueOffset = baseOffset + property.offset
                let byteRange = trueOffset..<(trueOffset + propertyTypeInfo.size)

                if byteRange.contains(offset) {
                    // The property is not this property but is within the byte range used by the value.
                    // So check its properties for the value at the offset.
                    return getPropertyName(
                        for: propertyTypeInfo,
                        baseOffset: property.offset + baseOffset,
                        path: path + [property.name]
                    )
                }
            }

            return nil
        }

        return getPropertyName(for: info, baseOffset: 0, path: [])
    }
}

This still won't always work though. If the child object is a class or a computed property it will fail, but if its all structs it seems to work.

Example:

struct Foo {
    let a: Int
    let bar: Bar
}

struct Bar {
    let b: Int
    let baz: Baz
}

struct Baz {
    let c: Int
}

let path = \Foo.bar.baz.c
print(path.propertyName) // prints "bar.baz.c"
yonaskolb commented 1 year ago

Oh that’s much better, many thanks! 🙏

alexlee002 commented 1 year ago

@wickwirew it seems not work in a class

alexlee002 commented 1 year ago

class C {
    var x: Int = 0
    var y: Int = 0
    var z: Int = 0
}

print(MemoryLayout<C>.offset(of: \.x)) // nil
print(MemoryLayout<C>.offset(of: \.y)) // nil
print(MemoryLayout<C>.offset(of: \.z)) // nil
alexlee002 commented 1 year ago

here is the KeyPaths implementation of Apple/swift:

@usableFromInline // Exposed as public API by MemoryLayout<Root>.offset(of:)
  internal var _storedInlineOffset: Int? {
    return withBuffer {
      var buffer = $0

      // The identity key path is effectively a stored keypath of type Self
      // at offset zero
      if buffer.data.isEmpty { return 0 }

      var offset = 0
      while true {
        let (rawComponent, optNextType) = buffer.next()
        switch rawComponent.header.kind {
        case .struct:
          offset += rawComponent._structOrClassOffset

        case .class, .computed, .optionalChain, .optionalForce, .optionalWrap, .external:
          return .none
        }

        if optNextType == nil { return .some(offset) }
      }
    }
  }
}
wickwirew commented 1 year ago

@alexlee002 yea that is actually the expected behavior due to the way MemoryLayout.offset(of:) works