From e32f10fcd3b40dbfbf70ca2e66215e4b9d134bd3 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 24 Jun 2025 14:54:46 -0400 Subject: [PATCH 1/3] Stub dom.ex so it can build --- .../DistributedMacros/DistributedMacros.swift | 7 + .../DistributedMacros/RemoteDeclaration.swift | 0 .../DistributedMacros/StableName.swift | 6 + .../DistributedMacros/StableNames.swift | 7 + .../Sources/ElixirKitCrane/ETF/Codable.swift | 0 .../ElixirKitCrane/ETF/ErlangTermBuffer.swift | 0 .../Sources/ElixirKitCrane/ETF/Function.swift | 0 .../Sources/ElixirKitCrane/ETF/PID.swift | 0 .../Sources/ElixirKitCrane/ETF/Port.swift | 0 .../ElixirKitCrane/ETF/Reference.swift | 0 .../Sources/ElixirKitCrane/ETF/Term.swift | 641 ++++++++++++++++++ .../ElixirKitCrane/ETF/TermDecoder.swift | 0 .../ElixirKitCrane/ETF/TermEncoder.swift | 0 .../Sources/ElixirKitCrane/Elixir.swift | 0 .../ElixirKitCrane/ErlangActorSystem.swift | 0 .../Sources/ElixirKitCrane/RPC.swift | 252 +++++++ .../ErlangActorSystemTests.swift | 0 .../browser/window/scripts/live_view/dom.ex | 23 +- .../window/scripts/live_view/dom_patch.ex | 6 +- 19 files changed, 932 insertions(+), 10 deletions(-) create mode 100644 ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift create mode 100644 ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift create mode 100644 ElixirKitCrane/Sources/DistributedMacros/StableName.swift create mode 100644 ElixirKitCrane/Sources/DistributedMacros/StableNames.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift create mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift create mode 100644 ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift diff --git a/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift b/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift new file mode 100644 index 0000000..07d7748 --- /dev/null +++ b/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift @@ -0,0 +1,7 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct DistributedMacros: CompilerPlugin { + var providingMacros: [Macro.Type] = [StableName.self] +} diff --git a/ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift b/ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/DistributedMacros/StableName.swift b/ElixirKitCrane/Sources/DistributedMacros/StableName.swift new file mode 100644 index 0000000..d5d7c52 --- /dev/null +++ b/ElixirKitCrane/Sources/DistributedMacros/StableName.swift @@ -0,0 +1,6 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +public struct StableName: PeerMacro { + +} diff --git a/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift b/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift new file mode 100644 index 0000000..ed6ca65 --- /dev/null +++ b/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift @@ -0,0 +1,7 @@ +// +// Untitled.swift +// ElixirKitCrane +// +// Created by Carson.Katri on 4/18/25. +// + diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift new file mode 100644 index 0000000..ea4a59e --- /dev/null +++ b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift @@ -0,0 +1,641 @@ +import Observation +import Foundation +import Combine +import erlang + +extension Array { + init(tuple: Tuple, start: KeyPath) { + self = withUnsafePointer(to: tuple) { pointer in + return [Element](UnsafeBufferPointer( + start: pointer.pointer(to: start)!, + count: MemoryLayout.size(ofValue: pointer.pointee) / MemoryLayout.size(ofValue: pointer.pointee[keyPath: start]) + )) + } + } +} + +extension String { + func tuple() -> (repeat each T) { + var result: (repeat each T) = (repeat (each T).zero) + withUnsafeMutableBytes(of: &result) { pointer in + pointer.copyBytes(from: utf8.prefix(pointer.count)) + } + return result + } +} + +/// A generic type that can be returned by an ``ErlangNode`` representing any +/// term. +public enum Term: Sendable, Hashable { + case int(Int) + case double(Double) + + case atom(String) + + case string(String) + + case ref(Reference) + + case port(Port) + + case pid(PID) + + case tuple([Term]) + + case list([Term]) + + case binary(Data) + + case bitstring(Data) + + case function(Function) + + case map([Term:Term]) + + public struct Reference: Sendable, Hashable { + var ref: erlang_ref + + public static func == (lhs: Self, rhs: Self) -> Bool { + var lhs = lhs + var rhs = rhs + return ei_cmp_refs(&lhs.ref, &rhs.ref) == 0 + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ref.creation) + hasher.combine(ref.len) + hasher.combine(Array(tuple: ref.n, start: \.0)) + hasher.combine(Array(tuple: ref.node, start: \.0)) + } + } + + public struct Port: Sendable, Hashable { + var port: erlang_port + + public static func == (lhs: Self, rhs: Self) -> Bool { + var lhs = lhs + var rhs = rhs + return ei_cmp_ports(&lhs.port, &rhs.port) == 0 + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(port.creation) + hasher.combine(port.id) + hasher.combine(Array(tuple: port.node, start: \.0)) + } + } + + public struct PID: Sendable, Hashable, Decodable { + var pid: erlang_pid + + init(pid: erlang_pid) { + self.pid = pid + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + var lhs = lhs + var rhs = rhs + return ei_cmp_pids(&lhs.pid, &rhs.pid) == 0 + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(pid.creation) + hasher.combine(pid.num) + hasher.combine(pid.serial) + hasher.combine(Array(tuple: pid.node, start: \.0)) + } + + public init(from decoder: any Decoder) throws { + guard let decoder = decoder as? __TermDecoder + else { fatalError("PID cannot be decoded outside of TermDecoder") } + + var pid = erlang_pid() + ei_decode_pid(decoder.buffer.buff, &decoder.index, &pid) + self.pid = pid + } + } + + public final class Function: @unchecked Sendable, Hashable { + var fun: erlang_fun + + @MainActor private static var closures = [Term.PID: [(ErlangTermBuffer, Int32) throws -> ErlangTermBuffer]]() + @MainActor static func call( + callee: Term.PID, + id: Int, + arguments: sending ErlangTermBuffer, + argumentsStartIndex: Int32 + ) async throws -> sending ErlangTermBuffer { + try closures[callee]![id](arguments, argumentsStartIndex) + } + + init(fun: erlang_fun) { + self.fun = fun + } + + private struct ArgumentList< + each Argument: Decodable & Sendable + >: Decodable, Sendable { + let arguments: (repeat each Argument) + + init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + self.arguments = ( + repeat try container.decode((each Argument).self) + ) + } + } + + /// Creates a closure that can be called from Elixir. + /// + /// Any closure you send to Elixir can be called an arbitrary number of + /// times. + /// + /// - Warning: Closures do *not* support reentrancy. The remote node + /// that calls the function will block until the closure returns. + @MainActor public init< + each Argument: Decodable & Sendable, + each Result: Encodable & Sendable + >( + callee: Term.PID, + _ action: sending @escaping (repeat each Argument) throws -> (repeat each Result) + ) { + let id = Self.closures.count + + Self.closures[callee, default: []].append({ buffer, startIndex in + let arguments = try TermDecoder().decode( + ArgumentList.self, + from: buffer, + startIndex: startIndex + ).arguments + + let result = try action(repeat each arguments) + + let buffer = ErlangTermBuffer() + buffer.newWithVersion() + + var resultCount = 0 + repeat ((each Result).self, resultCount += 1) + + switch resultCount { + case 0: + buffer.encode(atom: "ok") + case 1: + let encoder = TermEncoder() + encoder.options.includeVersion = false + for result in repeat (each result) { + buffer.append(try encoder.encode(result)) + } + default: + buffer.encode(tupleHeader: resultCount) + + let encoder = TermEncoder() + encoder.options.includeVersion = false + for result in repeat (each result) { + buffer.append(try encoder.encode(result)) + } + } + + return buffer + }) + + let annotation = 1 + + var argumentCount = 0 + repeat ((each Argument).self, argumentCount += 1) + + // `free_fun` frees the buffer for us, so we can create the buffer + // here without the `ErlangTermBuffer` and not worry about lifecycle + var freeVars = ei_x_buff() + ei_x_new(&freeVars) + + // {annotation, bindings, local handler, external handler, %{}, clauses} + ei_x_encode_tuple_header(&freeVars, 6) + ei_x_encode_long(&freeVars, annotation) + + ei_x_encode_map_header(&freeVars, 1) // bindings + ei_x_encode_atom(&freeVars, "_@1") // pid + var pid = callee.pid + ei_x_encode_pid(&freeVars, &pid) + + // {:value, &elixir.eval_local_handler/2} + ei_x_encode_tuple_header(&freeVars, 2) + ei_x_encode_atom(&freeVars, "value") + var eval_local_handler = Function("elixir", "eval_local_handler", 2) + ei_x_encode_fun(&freeVars, &eval_local_handler.fun) + + // {:value, &elixir.eval_external_handler/3} + ei_x_encode_tuple_header(&freeVars, 2) + ei_x_encode_atom(&freeVars, "value") + var eval_external_handler = Function("elixir", "eval_external_handler", 3) + ei_x_encode_fun(&freeVars, &eval_external_handler.fun) + + ei_x_encode_map_header(&freeVars, 0) // %{} + + ei_x_encode_list_header(&freeVars, 1) // clauses + + // encode Erlang AST for function clause + func tupleAST(arity: Int, _ build: () -> ()) { + ei_x_encode_tuple_header(&freeVars, 3) + ei_x_encode_atom(&freeVars, "tuple") + ei_x_encode_long(&freeVars, annotation) + ei_x_encode_list_header(&freeVars, arity) // elements + build() + ei_x_encode_empty_list(&freeVars) // elements tail + } + + func atomAST( _ atom: String) { + ei_x_encode_tuple_header(&freeVars, 3) + ei_x_encode_atom(&freeVars, "atom") + ei_x_encode_long(&freeVars, annotation) + ei_x_encode_atom(&freeVars, atom) + } + + func varAST(_ name: String) { + ei_x_encode_tuple_header(&freeVars, 3) + ei_x_encode_atom(&freeVars, "var") + ei_x_encode_long(&freeVars, annotation) + ei_x_encode_atom(&freeVars, name) + } + + /// - Note: You must encode another term to add to the list + func consHeaderAST(lhs: () -> ()) { + ei_x_encode_tuple_header(&freeVars, 4) + ei_x_encode_atom(&freeVars, "cons") + ei_x_encode_long(&freeVars, annotation) + lhs() + } + + // {:clause, ANNO, pattern, guard, body} + ei_x_encode_tuple_header(&freeVars, 5) + + ei_x_encode_atom(&freeVars, "clause") // clause + ei_x_encode_long(&freeVars, annotation) + // pattern + if argumentCount == 0 { + ei_x_encode_empty_list(&freeVars) + } else { + ei_x_encode_list_header(&freeVars, argumentCount) + for argument in 0.. res end + ei_x_encode_list_header(&freeVars, 2) // body + + // send(pid, {:fn, :hello}) + ei_x_encode_tuple_header(&freeVars, 4) + ei_x_encode_atom(&freeVars, "call") + ei_x_encode_long(&freeVars, annotation) + + ei_x_encode_tuple_header(&freeVars, 4) + ei_x_encode_atom(&freeVars, "remote") + ei_x_encode_long(&freeVars, annotation) + + atomAST("erlang") + atomAST("send") + + ei_x_encode_list_header(&freeVars, 2) // args list + + varAST("_@1") + // {:call, id, sender, [args...]} + tupleAST(arity: 4) { + atomAST("call") + + // {:integer, annotation, id} + ei_x_encode_tuple_header(&freeVars, 3) + ei_x_encode_atom(&freeVars, "integer") + ei_x_encode_long(&freeVars, annotation) + ei_x_encode_long(&freeVars, id) + + // self() + ei_x_encode_tuple_header(&freeVars, 4) + ei_x_encode_atom(&freeVars, "call") + ei_x_encode_long(&freeVars, annotation) + ei_x_encode_tuple_header(&freeVars, 4) + ei_x_encode_atom(&freeVars, "remote") + ei_x_encode_long(&freeVars, annotation) + atomAST("erlang") + atomAST("self") + ei_x_encode_empty_list(&freeVars) + + // args + if argumentCount == 0 { + ei_x_encode_tuple_header(&freeVars, 2) + ei_x_encode_atom(&freeVars, "nil") + ei_x_encode_long(&freeVars, annotation) + } else { + for argument in 0.. res end + ei_x_encode_tuple_header(&freeVars, 3) + ei_x_encode_atom(&freeVars, "receive") + ei_x_encode_long(&freeVars, annotation) + + ei_x_encode_list_header(&freeVars, 1) // clauses + + // {:clause, annotation, bindings, guard, body} + ei_x_encode_tuple_header(&freeVars, 5) + ei_x_encode_atom(&freeVars, "clause") // clause + ei_x_encode_long(&freeVars, annotation) + + ei_x_encode_list_header(&freeVars, 1) // pattern + varAST("_res@1") + ei_x_encode_empty_list(&freeVars) // pattern tail + + ei_x_encode_empty_list(&freeVars) // guard + + ei_x_encode_list_header(&freeVars, 1) // body + varAST("_res@1") + ei_x_encode_empty_list(&freeVars) // receive body tail + + ei_x_encode_empty_list(&freeVars) // receive clauses tail + + ei_x_encode_empty_list(&freeVars) // body tail + + ei_x_encode_empty_list(&freeVars) // clauses tail + + self.fun = erlang_fun( + arity: argumentCount, + module: "erl_eval".tuple(), + type: EI_FUN_CLOSURE, + u: erlang_fun.__Unnamed_union_u(closure: erlang_fun.__Unnamed_union_u.__Unnamed_struct_closure( + md5: "".tuple(), + index: Int(pid.num), + old_index: 0, + uniq: id, + n_free_vars: 1, + pid: pid, + free_var_len: Int(freeVars.buffsz), + free_vars: freeVars.buff + )) + ) + } + + public init( + _ module: String, + _ function: String, + _ arity: Int + ) { + self.fun = erlang_fun( + arity: arity, + module: module.tuple(), + type: EI_FUN_EXPORT, + u: erlang_fun.__Unnamed_union_u( + exprt: erlang_fun.__Unnamed_union_u.__Unnamed_struct_exprt( + func: strdup(function), + func_allocated: 0 + ) + ) + ) + } + + deinit { + free_fun(&fun) + } + + public static func == (lhs: Function, rhs: Function) -> Bool { + lhs.hashValue == rhs.hashValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(fun.arity) + hasher.combine(fun.type) + hasher.combine(Array(tuple: fun.module, start: \.0)) + + hasher.combine(fun.u.closure.free_var_len) + hasher.combine(fun.u.closure.free_vars) + hasher.combine(fun.u.closure.index) + hasher.combine(fun.u.closure.n_free_vars) + hasher.combine(fun.u.closure.old_index) + hasher.combine(fun.u.closure.old_index) + hasher.combine(fun.u.closure.uniq) + hasher.combine(Array(tuple: fun.u.closure.md5, start: \.0)) + hasher.combine(PID(pid: fun.u.closure.pid)) + + hasher.combine(fun.u.exprt.func) + hasher.combine(fun.u.exprt.func_allocated) + } + } + + func encode(to buffer: inout ErlangTermBuffer, initializeBuffer: Bool = true) throws { + if initializeBuffer { + guard buffer.newWithVersion() + else { throw TermError.encodingError } + } + + switch self { + case let .int(int): + guard buffer.encode(long: int) + else { throw TermError.encodingError } + case let .double(double): + guard buffer.encode(double: double) + else { throw TermError.encodingError } + case let .atom(atom): + guard buffer.encode(atom: strdup(atom)) + else { throw TermError.encodingError } + case var .ref(ref): + guard buffer.encode(ref: &ref.ref) + else { throw TermError.encodingError } + case var .port(port): + guard buffer.encode(port: &port.port) + else { throw TermError.encodingError } + case var .pid(pid): + guard buffer.encode(pid: &pid.pid) + else { throw TermError.encodingError } + case let .tuple(terms): + guard buffer.encode(tupleHeader: terms.count) + else { throw TermError.encodingError } + for term in terms { + try term.encode(to: &buffer, initializeBuffer: false) + } + case let .list(list) where list.isEmpty: + guard buffer.encode(listHeader: list.count) + else { throw TermError.encodingError } + case let .list(list): + guard buffer.encode(listHeader: list.count) + else { throw TermError.encodingError } + for term in list { + try term.encode(to: &buffer, initializeBuffer: false) + } + guard buffer.encodeEmptyList() + else { throw TermError.encodingError } + case let .binary(binary): + guard binary.withUnsafeBytes({ pointer in + buffer.encode(binary: pointer.baseAddress!, len: Int32(pointer.count)) + }) + else { throw TermError.encodingError } + case let .bitstring(bitstring): + guard bitstring.withUnsafeBytes({ pointer in + buffer.encode(bitstring: pointer.baseAddress!, bitoffs: 0, bits: pointer.count * UInt8.bitWidth) + }) + else { throw TermError.encodingError } + case var .function(function): + guard buffer.encode(fun: &function.fun) + else { throw TermError.encodingError } + case let .map(map): + guard buffer.encode(mapHeader: map.count) + else { throw TermError.encodingError } + for (key, value) in map { + try key.encode(to: &buffer, initializeBuffer: false) + try value.encode(to: &buffer, initializeBuffer: false) + } + case let .string(string): + guard buffer.encode(string: strdup(string)) + else { throw TermError.encodingError } + } + } + + public init( + from buffer: ErlangTermBuffer + ) throws { + var index: Int32 = 0 + + var version: Int32 = 0 + buffer.decode(version: &version, index: &index) +// guard ei_decode_version(buffer.buff, &index, &version) == 0 +// else { throw TermError.decodingError(.missingVersion) } + + func decodeNext() throws -> Self { + var type: UInt32 = 0 + var size: Int32 = 0 + buffer.getType(type: &type, size: &size, index: &index) + + switch Character(UnicodeScalar(type)!) { + case "a", "b": // integer + var int: Int = 0 + guard buffer.decode(long: &int, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .int(int) + case "c", "F": // float + var double: Double = 0 + guard buffer.decode(double: &double, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .double(double) + case "d", "s", "v": // atom + var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) + guard buffer.decode(atom: &atom, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .atom(String(cString: atom)) + case "e", "r", "Z": // ref + var ref: erlang_ref = erlang_ref() + guard buffer.decode(ref: &ref, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .ref(.init(ref: ref)) + case "f", "Y", "x": // port + var port: erlang_port = erlang_port() + guard buffer.decode(port: &port, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .port(.init(port: port)) + case "g", "X": // pid + var pid = erlang_pid() + guard buffer.decode(pid: &pid, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .pid(.init(pid: pid)) + case "h", "i": // tuple + var arity: Int32 = 0 + guard buffer.decode(tupleHeader: &arity, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .tuple(try (0.. = .allocate(capacity: Int(size) + 1) + defer { string.deallocate() } + guard buffer.decode(string: string, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .string(String(cString: string)) + case "l": // list + var arity: Int32 = 0 + guard buffer.decode(listHeader: &arity, index: &index) + else { throw TermError.decodingError(.badTerm) } + let elements = try (0..? + var bitOffset: UInt32 = 0 + var bits: Int = 0 + guard buffer.decode(bitstring: &pointer, bitoffsp: &bitOffset, nbitsp: &bits, index: &index) + else { throw TermError.decodingError(.badTerm) } + guard bitOffset == 0 + else { throw TermError.decodingError(.unsupportedBitOffset(bitOffset)) } + return .bitstring(pointer.map { + Data(bytes: $0, count: bits / UInt8.bitWidth) + } ?? Data()) + case "p", "u", "q": // function + var fun: erlang_fun = erlang_fun() + guard buffer.decode(fun: &fun, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .function(.init(fun: fun)) + case "t": // map + var arity: Int32 = 0 + guard buffer.decode(mapHeader: &arity, index: &index) + else { throw TermError.decodingError(.badTerm) } + return .map(Dictionary(uniqueKeysWithValues: try (0.. ErlangTermBuffer { + var buffer = ErlangTermBuffer() + + try encode(to: &buffer) + + return buffer + } + + enum TermError: Error { + case encodingError + case decodingError(DecodingError) + + enum DecodingError { + case missingVersion + case badTerm + case unknownType(Character) + + case unsupportedBitOffset(UInt32) + + case missingListEnd + } + } +} diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift b/ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift new file mode 100644 index 0000000..e69de29 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift b/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift new file mode 100644 index 0000000..814a623 --- /dev/null +++ b/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift @@ -0,0 +1,252 @@ +import erlang + +extension ErlangNode.Connection { + private func sendRPC( + _ module: String, + _ function: String, + _ arguments: [Term] + ) throws { + var args = ErlangTermBuffer() + args.new() + + try Term.list(arguments).encode(to: &args, initializeBuffer: false) + + var result = ErlangTermBuffer() + result.new() + + guard ei_rpc_to( + &node.node, + fileDescriptor, + strdup(module), + strdup(function), + args.buff, + args.index + ) == 0 + else { throw ErlangNodeError.rpcFailed } + } + + private func sendRPC( + _ module: String, + _ function: String, + _ arguments: [any (Encodable & Sendable)] + ) throws { + let encoder = TermEncoder() + encoder.options.includeVersion = false + let args = try encoder.encode( + arguments.map(RPCArgument.init(item:)) + ) + + var result = ErlangTermBuffer() + result.new() + + guard ei_rpc_to( + &node.node, + fileDescriptor, + strdup(module), + strdup(function), + args.buff, + args.index + ) == 0 + else { throw ErlangNodeError.rpcFailed } + } + + /// Makes an RPC call with a ``Term`` result type and ``Term`` + /// arguments. + /// + /// > Elixir modules must be prefixed with `Elixir.` + /// > + /// > By default, the ``module`` argument will refer to an Erlang module. + public func rpc( + _ module: String, + _ function: String, + _ arguments: [Term] + ) async throws -> Term { + let stream = self.messageBuffers() + + try await self.sendRPC(module, function, arguments) + + for try await message in stream { // find the :rex message + let message = try message.get() + + var index: Int32 = 0 + var arity: Int32 = 0 + var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) + + var version: Int32 = 0 + message.decode(version: &version, index: &index) + + message.decode(tupleHeader: &arity, index: &index) + + guard arity > 0, + message.decode(atom: &atom, index: &index), + String(cString: atom, encoding: .utf8) == "rex" + else { continue } + + return try Term(from: message) + } + + throw ErlangNodeError.rpcFailed + } + + public func rpc( + _ module: String, + _ function: String, + _ arguments: [Term] + ) async throws -> (repeat each Result) { + let stream = self.messageBuffers() + + try await self.sendRPC(module, function, arguments) + + for try await message in stream { // find the :rex message + let message = try message.get() + + var index: Int32 = 0 + var arity: Int32 = 0 + var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) + + var version: Int32 = 0 + message.decode(version: &version, index: &index) + + message.decode(tupleHeader: &arity, index: &index) + + guard arity > 0, + message.decode(atom: &atom, index: &index), + String(cString: atom, encoding: .utf8) == "rex" + else { continue } + + return try TermDecoder().decode( + RPCResult.self, + from: message, + startIndex: index + ).value + } + + throw ErlangNodeError.rpcFailed + } + + /// Makes an RPC call with a ``Swift/Decodable`` result type and + /// ``Swift/Encodable`` arguments. + /// + /// You can pass any `Encodable` type as an argument, and receive any number of + /// ``Decodable`` types as a response. + /// + /// ```swift + /// struct Version: Decodable { + /// let major: Int + /// let minor: Int + /// let patch: Int + /// let pre: [String] + /// } + /// + /// let version: Version = connection.rpc("Elixir.Version", "parse", ["2.0.1-alpha1"]) + /// ``` + /// + /// Assign the result to a tuple to decode multiple values. + /// + /// ```swift + /// // from Elixir: {:ok, "John Doe", 36} + /// let name: String, age: Int + /// (name, age) = try await connection.rpc("Elixir.User", "get", [0]) + /// ``` + /// + /// > Elixir modules must be prefixed with `Elixir.` + /// > + /// > By default, the ``module`` argument will refer to an Erlang module. + public func rpc( + _ module: String, + _ function: String, + _ arguments: [any (Encodable & Sendable)] + ) async throws -> (repeat each Result) { + let stream = self.messageBuffers() + + try await self.sendRPC(module, function, arguments) + + for try await message in stream { // find the :rex message + let message = try message.get() + + var index: Int32 = 0 + var arity: Int32 = 0 + var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) + + var version: Int32 = 0 + message.decode(version: &version, index: &index) + + message.decode(tupleHeader: &arity, index: &index) + + guard arity > 0, + message.decode(atom: &atom, index: &index), + String(cString: atom, encoding: .utf8) == "rex" + else { continue } + + return try TermDecoder().decode( + RPCResult.self, + from: message, + startIndex: index + ).value + } + + throw ErlangNodeError.rpcFailed + } + + private func receive() throws -> ErlangTermBuffer? { + var message = erlang_msg() + var buffer = ErlangTermBuffer() + buffer.new() + + switch ei_xreceive_msg_tmo(self.fileDescriptor, &message, &buffer.buffer, 1) { + case ERL_TICK: + return nil + case ERL_ERROR: + return nil + default: + return buffer + } + } + + /// Receives one message from a remote node, or `nil` if there are no + /// messages to receive. + public func receive() throws -> Term? { + return try receive().flatMap({ + var buffer = $0 + return try Term(from: buffer) + }) + } + + /// Receives one ``Swift/Decodable`` message from a remote node, or + /// `nil` if there are no messages to receive. + public func receive() throws -> Result? { + return try receive().flatMap({ try termDecoder.decode(Result.self, from: $0) }) + } + + private enum RPCStatus: String, Decodable { + case ok + case badrpc + } + + private struct RPCArgument: Encodable { + let item: any Encodable + + func encode(to encoder: any Encoder) throws { + var container = try encoder.singleValueContainer() + try container.encode(item) + } + } + + private struct RPCResult: Decodable { + let status: RPCStatus + let value: (repeat each Value) + + init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + self.status = try container.decode(RPCStatus.self) + switch self.status { + case .ok: + self.value = ( + repeat try container.decode((each Value).self) + ) + case .badrpc: + throw ErlangNodeError.rpcFailed + } + } + } +} diff --git a/ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift b/ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift new file mode 100644 index 0000000..e69de29 diff --git a/lib/crane/browser/window/scripts/live_view/dom.ex b/lib/crane/browser/window/scripts/live_view/dom.ex index 8cb3098..f883af3 100644 --- a/lib/crane/browser/window/scripts/live_view/dom.ex +++ b/lib/crane/browser/window/scripts/live_view/dom.ex @@ -15,7 +15,7 @@ defmodule LiveView.DOM do } def by_id(%Document{} = document, id) do - GenDOM.Doccument.get_element_by_id(document, id) + GenDOM.Document.get_element_by_id(document, id) |> case do nil -> # log - "no id found for #{id}" @@ -65,7 +65,7 @@ defmodule LiveView.DOM do end def find_upload_inputs(node) do - document = GenServer.call(node.owner_docuemnt, :get) + document = GenServer.call(node.owner_document, :get) form_id = node.id inputs_outside_form = all(document, ~s'input[type="file"][#{@phx_upload_ref}][form="#{form_id}"])') @@ -76,7 +76,7 @@ defmodule LiveView.DOM do all(document, ~s'[#{@phx_view_ref}="#{view_id}"][#{@phx_component}="#{cid}"]') end - def is_phx_destoryed?(node) do + def is_phx_destroyed?(node) do !!(node.id == "" && private(node, "destroyed")) end @@ -119,7 +119,7 @@ defmodule LiveView.DOM do end def find_existing_parent_cids(document, view_id, cids) do - {parent_cids, children_cids} = + {parent_cids, children_cids} = Enum.reduce(cids, {MapSet.new(), MapSet.new()}, fn(cid, {parent_cids, children_cids}) -> all(document, ~s'[#{@phx_view_ref}="#{view_id}"][#{@phx_component}="#{cid}"]') |> Enum.reduce({parent_cids, children_cids}, fn(parent, {parent_cids, children_cids}) -> @@ -233,9 +233,9 @@ defmodule LiveView.DOM do else callback.() task = Task.async(fn -> - :timer.sleep timout + :timer.sleep timeout if async_filter.(), - trigger_cycle(element, @debounce_trigger) + do: trigger_cycle(element, @debounce_trigger) end) put_private(element, @throttled, task) @@ -252,5 +252,14 @@ defmodule LiveView.DOM do end end end - + + def inc_cycle(a, b, c), do: raise "not implemented" + + def once(a, b), do: raise "not implemented" + + def is_phx_child?(element), do: raise "not implemented" + + def trigger_cycle(element, trigger, cycle \\ nil), do: raise "not implemented" + + def find_phx_children(a, b), do: raise "not implemented" end diff --git a/lib/crane/browser/window/scripts/live_view/dom_patch.ex b/lib/crane/browser/window/scripts/live_view/dom_patch.ex index a28b353..39bfb21 100644 --- a/lib/crane/browser/window/scripts/live_view/dom_patch.ex +++ b/lib/crane/browser/window/scripts/live_view/dom_patch.ex @@ -21,7 +21,7 @@ defmodule LiveView.DOMPatch do view: nil, live_socket: nil, container: nil, - id: nil, + id: nil, root_id: nil, markup: nil, streams: nil, @@ -125,7 +125,7 @@ defmodule LiveView.DOMPatch do def mark_prunable_content_for_removal(%__MODULE__{} = dom_patch) do phx_update = LiveSocket.binding(dom_patch.live_socket, @phx_update) - container = Floki.find_and_udpate(dom_patch.container, "[#{phx_update}=appennd] > *, [#{phx_update}=prepend] > *", fn + container = Floki.find_and_update(dom_patch.container, "[#{phx_update}=append] > *, [#{phx_update}=prepend] > *", fn {tag_name, attributes, children} -> attributes = Enum.into(attributes, %{}) @@ -145,6 +145,6 @@ defmodule LiveView.DOMPatch do end def set_stream_ref(el, ref) do - + end end From 87a2ff3a7654ad0b49b4e967813c023a82aa3213 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 24 Jun 2025 14:55:16 -0400 Subject: [PATCH 2/3] Remove ElixirKitCrane ETF --- .../DistributedMacros/DistributedMacros.swift | 7 - .../DistributedMacros/RemoteDeclaration.swift | 0 .../DistributedMacros/StableName.swift | 6 - .../DistributedMacros/StableNames.swift | 7 - .../Sources/ElixirKitCrane/ETF/Codable.swift | 0 .../ElixirKitCrane/ETF/ErlangTermBuffer.swift | 0 .../Sources/ElixirKitCrane/ETF/Function.swift | 0 .../Sources/ElixirKitCrane/ETF/PID.swift | 0 .../Sources/ElixirKitCrane/ETF/Port.swift | 0 .../ElixirKitCrane/ETF/Reference.swift | 0 .../Sources/ElixirKitCrane/ETF/Term.swift | 641 ------------------ .../ElixirKitCrane/ETF/TermDecoder.swift | 0 .../ElixirKitCrane/ETF/TermEncoder.swift | 0 .../Sources/ElixirKitCrane/Elixir.swift | 0 .../ElixirKitCrane/ErlangActorSystem.swift | 0 .../Sources/ElixirKitCrane/RPC.swift | 252 ------- .../ErlangActorSystemTests.swift | 0 17 files changed, 913 deletions(-) delete mode 100644 ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift delete mode 100644 ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift delete mode 100644 ElixirKitCrane/Sources/DistributedMacros/StableName.swift delete mode 100644 ElixirKitCrane/Sources/DistributedMacros/StableNames.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift delete mode 100644 ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift delete mode 100644 ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift diff --git a/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift b/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift deleted file mode 100644 index 07d7748..0000000 --- a/ElixirKitCrane/Sources/DistributedMacros/DistributedMacros.swift +++ /dev/null @@ -1,7 +0,0 @@ -import SwiftCompilerPlugin -import SwiftSyntaxMacros - -@main -struct DistributedMacros: CompilerPlugin { - var providingMacros: [Macro.Type] = [StableName.self] -} diff --git a/ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift b/ElixirKitCrane/Sources/DistributedMacros/RemoteDeclaration.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/DistributedMacros/StableName.swift b/ElixirKitCrane/Sources/DistributedMacros/StableName.swift deleted file mode 100644 index d5d7c52..0000000 --- a/ElixirKitCrane/Sources/DistributedMacros/StableName.swift +++ /dev/null @@ -1,6 +0,0 @@ -import SwiftSyntax -import SwiftSyntaxMacros - -public struct StableName: PeerMacro { - -} diff --git a/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift b/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift deleted file mode 100644 index ed6ca65..0000000 --- a/ElixirKitCrane/Sources/DistributedMacros/StableNames.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// ElixirKitCrane -// -// Created by Carson.Katri on 4/18/25. -// - diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Codable.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/ErlangTermBuffer.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Function.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/PID.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Port.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Reference.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift deleted file mode 100644 index ea4a59e..0000000 --- a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/Term.swift +++ /dev/null @@ -1,641 +0,0 @@ -import Observation -import Foundation -import Combine -import erlang - -extension Array { - init(tuple: Tuple, start: KeyPath) { - self = withUnsafePointer(to: tuple) { pointer in - return [Element](UnsafeBufferPointer( - start: pointer.pointer(to: start)!, - count: MemoryLayout.size(ofValue: pointer.pointee) / MemoryLayout.size(ofValue: pointer.pointee[keyPath: start]) - )) - } - } -} - -extension String { - func tuple() -> (repeat each T) { - var result: (repeat each T) = (repeat (each T).zero) - withUnsafeMutableBytes(of: &result) { pointer in - pointer.copyBytes(from: utf8.prefix(pointer.count)) - } - return result - } -} - -/// A generic type that can be returned by an ``ErlangNode`` representing any -/// term. -public enum Term: Sendable, Hashable { - case int(Int) - case double(Double) - - case atom(String) - - case string(String) - - case ref(Reference) - - case port(Port) - - case pid(PID) - - case tuple([Term]) - - case list([Term]) - - case binary(Data) - - case bitstring(Data) - - case function(Function) - - case map([Term:Term]) - - public struct Reference: Sendable, Hashable { - var ref: erlang_ref - - public static func == (lhs: Self, rhs: Self) -> Bool { - var lhs = lhs - var rhs = rhs - return ei_cmp_refs(&lhs.ref, &rhs.ref) == 0 - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(ref.creation) - hasher.combine(ref.len) - hasher.combine(Array(tuple: ref.n, start: \.0)) - hasher.combine(Array(tuple: ref.node, start: \.0)) - } - } - - public struct Port: Sendable, Hashable { - var port: erlang_port - - public static func == (lhs: Self, rhs: Self) -> Bool { - var lhs = lhs - var rhs = rhs - return ei_cmp_ports(&lhs.port, &rhs.port) == 0 - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(port.creation) - hasher.combine(port.id) - hasher.combine(Array(tuple: port.node, start: \.0)) - } - } - - public struct PID: Sendable, Hashable, Decodable { - var pid: erlang_pid - - init(pid: erlang_pid) { - self.pid = pid - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - var lhs = lhs - var rhs = rhs - return ei_cmp_pids(&lhs.pid, &rhs.pid) == 0 - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(pid.creation) - hasher.combine(pid.num) - hasher.combine(pid.serial) - hasher.combine(Array(tuple: pid.node, start: \.0)) - } - - public init(from decoder: any Decoder) throws { - guard let decoder = decoder as? __TermDecoder - else { fatalError("PID cannot be decoded outside of TermDecoder") } - - var pid = erlang_pid() - ei_decode_pid(decoder.buffer.buff, &decoder.index, &pid) - self.pid = pid - } - } - - public final class Function: @unchecked Sendable, Hashable { - var fun: erlang_fun - - @MainActor private static var closures = [Term.PID: [(ErlangTermBuffer, Int32) throws -> ErlangTermBuffer]]() - @MainActor static func call( - callee: Term.PID, - id: Int, - arguments: sending ErlangTermBuffer, - argumentsStartIndex: Int32 - ) async throws -> sending ErlangTermBuffer { - try closures[callee]![id](arguments, argumentsStartIndex) - } - - init(fun: erlang_fun) { - self.fun = fun - } - - private struct ArgumentList< - each Argument: Decodable & Sendable - >: Decodable, Sendable { - let arguments: (repeat each Argument) - - init(from decoder: any Decoder) throws { - var container = try decoder.unkeyedContainer() - self.arguments = ( - repeat try container.decode((each Argument).self) - ) - } - } - - /// Creates a closure that can be called from Elixir. - /// - /// Any closure you send to Elixir can be called an arbitrary number of - /// times. - /// - /// - Warning: Closures do *not* support reentrancy. The remote node - /// that calls the function will block until the closure returns. - @MainActor public init< - each Argument: Decodable & Sendable, - each Result: Encodable & Sendable - >( - callee: Term.PID, - _ action: sending @escaping (repeat each Argument) throws -> (repeat each Result) - ) { - let id = Self.closures.count - - Self.closures[callee, default: []].append({ buffer, startIndex in - let arguments = try TermDecoder().decode( - ArgumentList.self, - from: buffer, - startIndex: startIndex - ).arguments - - let result = try action(repeat each arguments) - - let buffer = ErlangTermBuffer() - buffer.newWithVersion() - - var resultCount = 0 - repeat ((each Result).self, resultCount += 1) - - switch resultCount { - case 0: - buffer.encode(atom: "ok") - case 1: - let encoder = TermEncoder() - encoder.options.includeVersion = false - for result in repeat (each result) { - buffer.append(try encoder.encode(result)) - } - default: - buffer.encode(tupleHeader: resultCount) - - let encoder = TermEncoder() - encoder.options.includeVersion = false - for result in repeat (each result) { - buffer.append(try encoder.encode(result)) - } - } - - return buffer - }) - - let annotation = 1 - - var argumentCount = 0 - repeat ((each Argument).self, argumentCount += 1) - - // `free_fun` frees the buffer for us, so we can create the buffer - // here without the `ErlangTermBuffer` and not worry about lifecycle - var freeVars = ei_x_buff() - ei_x_new(&freeVars) - - // {annotation, bindings, local handler, external handler, %{}, clauses} - ei_x_encode_tuple_header(&freeVars, 6) - ei_x_encode_long(&freeVars, annotation) - - ei_x_encode_map_header(&freeVars, 1) // bindings - ei_x_encode_atom(&freeVars, "_@1") // pid - var pid = callee.pid - ei_x_encode_pid(&freeVars, &pid) - - // {:value, &elixir.eval_local_handler/2} - ei_x_encode_tuple_header(&freeVars, 2) - ei_x_encode_atom(&freeVars, "value") - var eval_local_handler = Function("elixir", "eval_local_handler", 2) - ei_x_encode_fun(&freeVars, &eval_local_handler.fun) - - // {:value, &elixir.eval_external_handler/3} - ei_x_encode_tuple_header(&freeVars, 2) - ei_x_encode_atom(&freeVars, "value") - var eval_external_handler = Function("elixir", "eval_external_handler", 3) - ei_x_encode_fun(&freeVars, &eval_external_handler.fun) - - ei_x_encode_map_header(&freeVars, 0) // %{} - - ei_x_encode_list_header(&freeVars, 1) // clauses - - // encode Erlang AST for function clause - func tupleAST(arity: Int, _ build: () -> ()) { - ei_x_encode_tuple_header(&freeVars, 3) - ei_x_encode_atom(&freeVars, "tuple") - ei_x_encode_long(&freeVars, annotation) - ei_x_encode_list_header(&freeVars, arity) // elements - build() - ei_x_encode_empty_list(&freeVars) // elements tail - } - - func atomAST( _ atom: String) { - ei_x_encode_tuple_header(&freeVars, 3) - ei_x_encode_atom(&freeVars, "atom") - ei_x_encode_long(&freeVars, annotation) - ei_x_encode_atom(&freeVars, atom) - } - - func varAST(_ name: String) { - ei_x_encode_tuple_header(&freeVars, 3) - ei_x_encode_atom(&freeVars, "var") - ei_x_encode_long(&freeVars, annotation) - ei_x_encode_atom(&freeVars, name) - } - - /// - Note: You must encode another term to add to the list - func consHeaderAST(lhs: () -> ()) { - ei_x_encode_tuple_header(&freeVars, 4) - ei_x_encode_atom(&freeVars, "cons") - ei_x_encode_long(&freeVars, annotation) - lhs() - } - - // {:clause, ANNO, pattern, guard, body} - ei_x_encode_tuple_header(&freeVars, 5) - - ei_x_encode_atom(&freeVars, "clause") // clause - ei_x_encode_long(&freeVars, annotation) - // pattern - if argumentCount == 0 { - ei_x_encode_empty_list(&freeVars) - } else { - ei_x_encode_list_header(&freeVars, argumentCount) - for argument in 0.. res end - ei_x_encode_list_header(&freeVars, 2) // body - - // send(pid, {:fn, :hello}) - ei_x_encode_tuple_header(&freeVars, 4) - ei_x_encode_atom(&freeVars, "call") - ei_x_encode_long(&freeVars, annotation) - - ei_x_encode_tuple_header(&freeVars, 4) - ei_x_encode_atom(&freeVars, "remote") - ei_x_encode_long(&freeVars, annotation) - - atomAST("erlang") - atomAST("send") - - ei_x_encode_list_header(&freeVars, 2) // args list - - varAST("_@1") - // {:call, id, sender, [args...]} - tupleAST(arity: 4) { - atomAST("call") - - // {:integer, annotation, id} - ei_x_encode_tuple_header(&freeVars, 3) - ei_x_encode_atom(&freeVars, "integer") - ei_x_encode_long(&freeVars, annotation) - ei_x_encode_long(&freeVars, id) - - // self() - ei_x_encode_tuple_header(&freeVars, 4) - ei_x_encode_atom(&freeVars, "call") - ei_x_encode_long(&freeVars, annotation) - ei_x_encode_tuple_header(&freeVars, 4) - ei_x_encode_atom(&freeVars, "remote") - ei_x_encode_long(&freeVars, annotation) - atomAST("erlang") - atomAST("self") - ei_x_encode_empty_list(&freeVars) - - // args - if argumentCount == 0 { - ei_x_encode_tuple_header(&freeVars, 2) - ei_x_encode_atom(&freeVars, "nil") - ei_x_encode_long(&freeVars, annotation) - } else { - for argument in 0.. res end - ei_x_encode_tuple_header(&freeVars, 3) - ei_x_encode_atom(&freeVars, "receive") - ei_x_encode_long(&freeVars, annotation) - - ei_x_encode_list_header(&freeVars, 1) // clauses - - // {:clause, annotation, bindings, guard, body} - ei_x_encode_tuple_header(&freeVars, 5) - ei_x_encode_atom(&freeVars, "clause") // clause - ei_x_encode_long(&freeVars, annotation) - - ei_x_encode_list_header(&freeVars, 1) // pattern - varAST("_res@1") - ei_x_encode_empty_list(&freeVars) // pattern tail - - ei_x_encode_empty_list(&freeVars) // guard - - ei_x_encode_list_header(&freeVars, 1) // body - varAST("_res@1") - ei_x_encode_empty_list(&freeVars) // receive body tail - - ei_x_encode_empty_list(&freeVars) // receive clauses tail - - ei_x_encode_empty_list(&freeVars) // body tail - - ei_x_encode_empty_list(&freeVars) // clauses tail - - self.fun = erlang_fun( - arity: argumentCount, - module: "erl_eval".tuple(), - type: EI_FUN_CLOSURE, - u: erlang_fun.__Unnamed_union_u(closure: erlang_fun.__Unnamed_union_u.__Unnamed_struct_closure( - md5: "".tuple(), - index: Int(pid.num), - old_index: 0, - uniq: id, - n_free_vars: 1, - pid: pid, - free_var_len: Int(freeVars.buffsz), - free_vars: freeVars.buff - )) - ) - } - - public init( - _ module: String, - _ function: String, - _ arity: Int - ) { - self.fun = erlang_fun( - arity: arity, - module: module.tuple(), - type: EI_FUN_EXPORT, - u: erlang_fun.__Unnamed_union_u( - exprt: erlang_fun.__Unnamed_union_u.__Unnamed_struct_exprt( - func: strdup(function), - func_allocated: 0 - ) - ) - ) - } - - deinit { - free_fun(&fun) - } - - public static func == (lhs: Function, rhs: Function) -> Bool { - lhs.hashValue == rhs.hashValue - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(fun.arity) - hasher.combine(fun.type) - hasher.combine(Array(tuple: fun.module, start: \.0)) - - hasher.combine(fun.u.closure.free_var_len) - hasher.combine(fun.u.closure.free_vars) - hasher.combine(fun.u.closure.index) - hasher.combine(fun.u.closure.n_free_vars) - hasher.combine(fun.u.closure.old_index) - hasher.combine(fun.u.closure.old_index) - hasher.combine(fun.u.closure.uniq) - hasher.combine(Array(tuple: fun.u.closure.md5, start: \.0)) - hasher.combine(PID(pid: fun.u.closure.pid)) - - hasher.combine(fun.u.exprt.func) - hasher.combine(fun.u.exprt.func_allocated) - } - } - - func encode(to buffer: inout ErlangTermBuffer, initializeBuffer: Bool = true) throws { - if initializeBuffer { - guard buffer.newWithVersion() - else { throw TermError.encodingError } - } - - switch self { - case let .int(int): - guard buffer.encode(long: int) - else { throw TermError.encodingError } - case let .double(double): - guard buffer.encode(double: double) - else { throw TermError.encodingError } - case let .atom(atom): - guard buffer.encode(atom: strdup(atom)) - else { throw TermError.encodingError } - case var .ref(ref): - guard buffer.encode(ref: &ref.ref) - else { throw TermError.encodingError } - case var .port(port): - guard buffer.encode(port: &port.port) - else { throw TermError.encodingError } - case var .pid(pid): - guard buffer.encode(pid: &pid.pid) - else { throw TermError.encodingError } - case let .tuple(terms): - guard buffer.encode(tupleHeader: terms.count) - else { throw TermError.encodingError } - for term in terms { - try term.encode(to: &buffer, initializeBuffer: false) - } - case let .list(list) where list.isEmpty: - guard buffer.encode(listHeader: list.count) - else { throw TermError.encodingError } - case let .list(list): - guard buffer.encode(listHeader: list.count) - else { throw TermError.encodingError } - for term in list { - try term.encode(to: &buffer, initializeBuffer: false) - } - guard buffer.encodeEmptyList() - else { throw TermError.encodingError } - case let .binary(binary): - guard binary.withUnsafeBytes({ pointer in - buffer.encode(binary: pointer.baseAddress!, len: Int32(pointer.count)) - }) - else { throw TermError.encodingError } - case let .bitstring(bitstring): - guard bitstring.withUnsafeBytes({ pointer in - buffer.encode(bitstring: pointer.baseAddress!, bitoffs: 0, bits: pointer.count * UInt8.bitWidth) - }) - else { throw TermError.encodingError } - case var .function(function): - guard buffer.encode(fun: &function.fun) - else { throw TermError.encodingError } - case let .map(map): - guard buffer.encode(mapHeader: map.count) - else { throw TermError.encodingError } - for (key, value) in map { - try key.encode(to: &buffer, initializeBuffer: false) - try value.encode(to: &buffer, initializeBuffer: false) - } - case let .string(string): - guard buffer.encode(string: strdup(string)) - else { throw TermError.encodingError } - } - } - - public init( - from buffer: ErlangTermBuffer - ) throws { - var index: Int32 = 0 - - var version: Int32 = 0 - buffer.decode(version: &version, index: &index) -// guard ei_decode_version(buffer.buff, &index, &version) == 0 -// else { throw TermError.decodingError(.missingVersion) } - - func decodeNext() throws -> Self { - var type: UInt32 = 0 - var size: Int32 = 0 - buffer.getType(type: &type, size: &size, index: &index) - - switch Character(UnicodeScalar(type)!) { - case "a", "b": // integer - var int: Int = 0 - guard buffer.decode(long: &int, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .int(int) - case "c", "F": // float - var double: Double = 0 - guard buffer.decode(double: &double, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .double(double) - case "d", "s", "v": // atom - var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) - guard buffer.decode(atom: &atom, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .atom(String(cString: atom)) - case "e", "r", "Z": // ref - var ref: erlang_ref = erlang_ref() - guard buffer.decode(ref: &ref, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .ref(.init(ref: ref)) - case "f", "Y", "x": // port - var port: erlang_port = erlang_port() - guard buffer.decode(port: &port, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .port(.init(port: port)) - case "g", "X": // pid - var pid = erlang_pid() - guard buffer.decode(pid: &pid, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .pid(.init(pid: pid)) - case "h", "i": // tuple - var arity: Int32 = 0 - guard buffer.decode(tupleHeader: &arity, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .tuple(try (0.. = .allocate(capacity: Int(size) + 1) - defer { string.deallocate() } - guard buffer.decode(string: string, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .string(String(cString: string)) - case "l": // list - var arity: Int32 = 0 - guard buffer.decode(listHeader: &arity, index: &index) - else { throw TermError.decodingError(.badTerm) } - let elements = try (0..? - var bitOffset: UInt32 = 0 - var bits: Int = 0 - guard buffer.decode(bitstring: &pointer, bitoffsp: &bitOffset, nbitsp: &bits, index: &index) - else { throw TermError.decodingError(.badTerm) } - guard bitOffset == 0 - else { throw TermError.decodingError(.unsupportedBitOffset(bitOffset)) } - return .bitstring(pointer.map { - Data(bytes: $0, count: bits / UInt8.bitWidth) - } ?? Data()) - case "p", "u", "q": // function - var fun: erlang_fun = erlang_fun() - guard buffer.decode(fun: &fun, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .function(.init(fun: fun)) - case "t": // map - var arity: Int32 = 0 - guard buffer.decode(mapHeader: &arity, index: &index) - else { throw TermError.decodingError(.badTerm) } - return .map(Dictionary(uniqueKeysWithValues: try (0.. ErlangTermBuffer { - var buffer = ErlangTermBuffer() - - try encode(to: &buffer) - - return buffer - } - - enum TermError: Error { - case encodingError - case decodingError(DecodingError) - - enum DecodingError { - case missingVersion - case badTerm - case unknownType(Character) - - case unsupportedBitOffset(UInt32) - - case missingListEnd - } - } -} diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermDecoder.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ETF/TermEncoder.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift b/ElixirKitCrane/Sources/ElixirKitCrane/Elixir.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift b/ElixirKitCrane/Sources/ElixirKitCrane/ErlangActorSystem.swift deleted file mode 100644 index e69de29..0000000 diff --git a/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift b/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift deleted file mode 100644 index 814a623..0000000 --- a/ElixirKitCrane/Sources/ElixirKitCrane/RPC.swift +++ /dev/null @@ -1,252 +0,0 @@ -import erlang - -extension ErlangNode.Connection { - private func sendRPC( - _ module: String, - _ function: String, - _ arguments: [Term] - ) throws { - var args = ErlangTermBuffer() - args.new() - - try Term.list(arguments).encode(to: &args, initializeBuffer: false) - - var result = ErlangTermBuffer() - result.new() - - guard ei_rpc_to( - &node.node, - fileDescriptor, - strdup(module), - strdup(function), - args.buff, - args.index - ) == 0 - else { throw ErlangNodeError.rpcFailed } - } - - private func sendRPC( - _ module: String, - _ function: String, - _ arguments: [any (Encodable & Sendable)] - ) throws { - let encoder = TermEncoder() - encoder.options.includeVersion = false - let args = try encoder.encode( - arguments.map(RPCArgument.init(item:)) - ) - - var result = ErlangTermBuffer() - result.new() - - guard ei_rpc_to( - &node.node, - fileDescriptor, - strdup(module), - strdup(function), - args.buff, - args.index - ) == 0 - else { throw ErlangNodeError.rpcFailed } - } - - /// Makes an RPC call with a ``Term`` result type and ``Term`` - /// arguments. - /// - /// > Elixir modules must be prefixed with `Elixir.` - /// > - /// > By default, the ``module`` argument will refer to an Erlang module. - public func rpc( - _ module: String, - _ function: String, - _ arguments: [Term] - ) async throws -> Term { - let stream = self.messageBuffers() - - try await self.sendRPC(module, function, arguments) - - for try await message in stream { // find the :rex message - let message = try message.get() - - var index: Int32 = 0 - var arity: Int32 = 0 - var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) - - var version: Int32 = 0 - message.decode(version: &version, index: &index) - - message.decode(tupleHeader: &arity, index: &index) - - guard arity > 0, - message.decode(atom: &atom, index: &index), - String(cString: atom, encoding: .utf8) == "rex" - else { continue } - - return try Term(from: message) - } - - throw ErlangNodeError.rpcFailed - } - - public func rpc( - _ module: String, - _ function: String, - _ arguments: [Term] - ) async throws -> (repeat each Result) { - let stream = self.messageBuffers() - - try await self.sendRPC(module, function, arguments) - - for try await message in stream { // find the :rex message - let message = try message.get() - - var index: Int32 = 0 - var arity: Int32 = 0 - var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) - - var version: Int32 = 0 - message.decode(version: &version, index: &index) - - message.decode(tupleHeader: &arity, index: &index) - - guard arity > 0, - message.decode(atom: &atom, index: &index), - String(cString: atom, encoding: .utf8) == "rex" - else { continue } - - return try TermDecoder().decode( - RPCResult.self, - from: message, - startIndex: index - ).value - } - - throw ErlangNodeError.rpcFailed - } - - /// Makes an RPC call with a ``Swift/Decodable`` result type and - /// ``Swift/Encodable`` arguments. - /// - /// You can pass any `Encodable` type as an argument, and receive any number of - /// ``Decodable`` types as a response. - /// - /// ```swift - /// struct Version: Decodable { - /// let major: Int - /// let minor: Int - /// let patch: Int - /// let pre: [String] - /// } - /// - /// let version: Version = connection.rpc("Elixir.Version", "parse", ["2.0.1-alpha1"]) - /// ``` - /// - /// Assign the result to a tuple to decode multiple values. - /// - /// ```swift - /// // from Elixir: {:ok, "John Doe", 36} - /// let name: String, age: Int - /// (name, age) = try await connection.rpc("Elixir.User", "get", [0]) - /// ``` - /// - /// > Elixir modules must be prefixed with `Elixir.` - /// > - /// > By default, the ``module`` argument will refer to an Erlang module. - public func rpc( - _ module: String, - _ function: String, - _ arguments: [any (Encodable & Sendable)] - ) async throws -> (repeat each Result) { - let stream = self.messageBuffers() - - try await self.sendRPC(module, function, arguments) - - for try await message in stream { // find the :rex message - let message = try message.get() - - var index: Int32 = 0 - var arity: Int32 = 0 - var atom: [CChar] = [CChar](repeating: 0, count: Int(MAXATOMLEN)) - - var version: Int32 = 0 - message.decode(version: &version, index: &index) - - message.decode(tupleHeader: &arity, index: &index) - - guard arity > 0, - message.decode(atom: &atom, index: &index), - String(cString: atom, encoding: .utf8) == "rex" - else { continue } - - return try TermDecoder().decode( - RPCResult.self, - from: message, - startIndex: index - ).value - } - - throw ErlangNodeError.rpcFailed - } - - private func receive() throws -> ErlangTermBuffer? { - var message = erlang_msg() - var buffer = ErlangTermBuffer() - buffer.new() - - switch ei_xreceive_msg_tmo(self.fileDescriptor, &message, &buffer.buffer, 1) { - case ERL_TICK: - return nil - case ERL_ERROR: - return nil - default: - return buffer - } - } - - /// Receives one message from a remote node, or `nil` if there are no - /// messages to receive. - public func receive() throws -> Term? { - return try receive().flatMap({ - var buffer = $0 - return try Term(from: buffer) - }) - } - - /// Receives one ``Swift/Decodable`` message from a remote node, or - /// `nil` if there are no messages to receive. - public func receive() throws -> Result? { - return try receive().flatMap({ try termDecoder.decode(Result.self, from: $0) }) - } - - private enum RPCStatus: String, Decodable { - case ok - case badrpc - } - - private struct RPCArgument: Encodable { - let item: any Encodable - - func encode(to encoder: any Encoder) throws { - var container = try encoder.singleValueContainer() - try container.encode(item) - } - } - - private struct RPCResult: Decodable { - let status: RPCStatus - let value: (repeat each Value) - - init(from decoder: any Decoder) throws { - var container = try decoder.unkeyedContainer() - self.status = try container.decode(RPCStatus.self) - switch self.status { - case .ok: - self.value = ( - repeat try container.decode((each Value).self) - ) - case .badrpc: - throw ErlangNodeError.rpcFailed - } - } - } -} diff --git a/ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift b/ElixirKitCrane/Tests/ErlangActorSystemTests/ErlangActorSystemTests.swift deleted file mode 100644 index e69de29..0000000 From e3dc4571d7639bcb1bfe085db17ce002edc8cd7b Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 24 Jun 2025 18:19:42 -0400 Subject: [PATCH 3/3] Add simple morphdom implementation --- .../window/scripts/live_view/morphdom.ex | 239 ++++++++++++++++++ test/live_view/morphdom.exs | 76 ++++++ 2 files changed, 315 insertions(+) create mode 100644 lib/crane/browser/window/scripts/live_view/morphdom.ex create mode 100644 test/live_view/morphdom.exs diff --git a/lib/crane/browser/window/scripts/live_view/morphdom.ex b/lib/crane/browser/window/scripts/live_view/morphdom.ex new file mode 100644 index 0000000..060b4da --- /dev/null +++ b/lib/crane/browser/window/scripts/live_view/morphdom.ex @@ -0,0 +1,239 @@ +defmodule LiveView.MorphDOM do + @element_node 1 + @text_node 3 + + def morph(from_node, to_node, opts \\ []) do + morph(from_node, to_node, opts, %{}) + end + + defp morph(from_node, to_node, opts, keyed_nodes) do + get_node_key = Keyword.get(opts, :get_node_key, fn node -> + if node.id == "" do + nil + else + node.id + end + end) + skip_from_children = Keyword.get(opts, :skip_from_children, fn _node -> false end) + add_child = Keyword.get(opts, :add_child, &GenDOM.Node.append_child/2) + on_before_node_added = Keyword.get(opts, :on_before_node_added, fn node -> node end) + on_node_added = Keyword.get(opts, :on_node_added, fn node -> node end) + on_before_node_discarded = Keyword.get(opts, :on_before_node_discarded, fn _node -> true end) + on_node_discarded = Keyword.get(opts, :on_node_discarded, fn node -> node end) + on_before_element_updated = Keyword.get(opts, :on_before_element_updated, fn from_element, _to_element -> from_element end) + on_element_updated = Keyword.get(opts, :on_element_updated, fn element -> element end) + children_only = Keyword.get(opts, :children_only) + + from_node = GenDOM.Node.get(from_node) + to_node = GenDOM.Node.get(to_node) + + zipped_children = :lists.zip( + Enum.map(from_node.child_nodes, &GenDOM.Node.get/1), + Enum.map(to_node.child_nodes, &GenDOM.Node.get/1), + {:pad, {nil, nil}} + ) + Enum.reduce(zipped_children, keyed_nodes, fn + {from_child, nil}, acc -> # element removed + on_before_node_discarded.(from_child) + GenDOM.Node.remove_child(from_node, from_child) + on_node_discarded.(from_child) + if key = get_node_key.(from_child) do + # keep track of keyed nodes, we might match them later + Map.put(acc, key, from_child) + else + acc + end + {nil, to_child}, acc -> # element added + on_before_node_added.(to_child) + add_child.(from_node, to_child) + on_node_added.(to_child) + acc + {%{ node_type: @element_node } = from_child, %{ node_type: @element_node } = to_child}, acc -> # element changed + if existing_child = Map.get(acc, get_node_key.(to_child)) do + + end + on_before_element_updated.(from_child, to_child) + Enum.each([:tag_name, :attributes, :class_list], fn prop -> + new_value = Map.get(to_child, prop) + if Map.get(from_child, prop) != new_value do + GenDOM.Node.put(from_child, prop, new_value) + end + end) + on_element_updated.(from_child) + morph( + from_child, + to_child, + opts, + if key = get_node_key.(from_child) do + Map.delete(acc, key) + else + acc + end + ) + { + %{ node_type: @text_node, whole_text: from_whole_text } = from_child, + %{ node_type: @text_node, whole_text: to_whole_text } = _to_child + }, acc when from_whole_text != to_whole_text -> # text changed + GenDOM.Node.put(from_child, :whole_text, to_whole_text) + acc + {%{ node_type: from_node_type } = from_child, %{ node_type: to_node_type } = to_child}, acc when from_node_type != to_node_type -> # text -> element or element -> text + on_before_node_discarded.(from_child) + on_before_node_added.(to_child) + # replace the child + GenDOM.Node.replace_child(from_node, to_child, from_child) + on_node_discarded.(from_child) + on_node_added.(to_child) + if key = get_node_key.(from_child) do + Map.put(acc, key, from_child) + else + acc + end + end) + end + + # def morphdom(from_node, to_node, opts \\ []) do + # get_node_key = Keyword.get(opts, :get_node_key, fn _node -> nil end) + # skip_from_children = Keyword.get(opts, :skip_from_children, fn _node -> false end) + # add_child = Keyword.get(opts, :add_child, &GenDOM.Node.append_child/2) + # on_before_node_added = Keyword.get(opts, :on_before_node_added, fn node -> node end) + # on_node_added = Keyword.get(opts, :on_node_added, fn node -> node end) + # on_before_node_discarded = Keyword.get(opts, :on_before_node_discarded, fn _node -> true end) + # on_node_discarded = Keyword.get(opts, :on_node_discarded, fn node -> node end) + # on_before_element_updated = Keyword.get(opts, :on_before_element_updated, fn from_element, to_element -> from_element end) + # on_element_updated = Keyword.get(opts, :on_element_updated, fn element -> element end) + # children_only = Keyword.get(opts, :children_only) + + # from_node = GenDOM.Node.get(from_node) + # morphed_node = from_node + # to_node = GenDOM.Node.get(to_node) + + # if not Keyword.get(opts, :children_only) do + # # handle two dom nodes that are not compatible. + # # (e.g.
--> or
--> TEXT) + # raise "todo" + # end + + # morphed_node = if GenDOM.Node.is_same_node?(to_node, morphed_node) do + # Keyword.get(opts, :on_node_discarded).(from_node) + # morphed_node + # else + # {morphed_node, keyed_removal_list, from_nodes_lookup} = morph_element(morphed_node, to_node, [], %{}, opts) + + # # We now need to loop over any keyed nodes that might need to be + # # removed. We only do the removal if we know that the keyed node + # # never found a match. When a keyed node is matched up we remove + # # it out of fromNodesLookup and we use fromNodesLookup to determine + # # if a keyed node has been matched up or not + # for key <- keyed_removal_list do + # element = Map.get(from_nodes_lookup, key) + # remove_node(element, element.parent, false, opts) + # end + + # if children_only and not GenDOM.Node.is_same_node?(morphed_node, from_node) and from_node.parent_node do + # # If we had to swap out the from node with a new node because the old + # # node was not compatible with the target node then we need to + # # replace the old DOM node in the original DOM tree. This is only + # # possible if the original DOM node was part of a DOM tree which + # # we know is the case if it has a parent node. + # raise "todo" + # else + # morphed_node + # end + # end + # end + + # # Removes a DOM node out of the original DOM. + # defp remove_node(node, parent, skip_keyed_nodes, opts) do + # if not Keyword.get(opts, :on_before_node_discarded).(node) do + # node + # else + # if parent != nil do + # GenDOM.Node.remove_child(parent, node) + # end + + # Keyword.get(opts, :on_node_discarded).(node) + # walk_discarded_child_nodes(node, skip_keyed_nodes) + # end + # end + + # # walks the children of the node and returns a list of keyed nodes. + # defp walk_discarded_child_nodes(node, skip_keyed_nodes, opts) when node.node_type == @element_node do + # node = GenDOM.Node.get(node) + # if node.first_child do + # walk_discarded_nodes(node.first_child, skip_keyed_nodes, opts) + # else + # [] + # end + # end + + # defp walk_discarded_nodes(node, skip_keyed_nodes, keyed_removal, opts) when node.node_type == @element_node do + # key = get_node_key(opts, node) + # keyed_removal = if skip_keyed_nodes and key do + # # If we are skipping keyed nodes then we add the key + # # to a list so that it can be handled at the very end. + # [key | keyed_removal] + # else + # # Only report the node as discarded if it is not keyed. We do this because + # # at the end we loop through all keyed elements that were unmatched + # # and then discard them in one final pass. + # opts.on_node_discarded(node) + # walk_discarded_child_nodes(node, skip_keyed_nodes, opts) + # end + # walk_discarded_nodes(GenDOM.Node.get(node).next_sibling, skip_keyed_nodes, opts) + # end + + # defp walk_discarded_nodes(_node, _skip_keyed_nodes, keyed_removal, _opts) do + # keyed_removal + # end + + # defp morph_element(from_node, to_node, keyed_removal_list, from_nodes_lookup, opts) do + # to_key = opts.get_node_key(to_node) + # from_nodes_lookup = if to_key do + # Map.delete(from_nodes_lookup, to_key) + # else + # from_nodes_lookup + # end + + # if not opts.children_only do + # raise "todo" + # end + + # morph_children(from_node, to_node, keyed_removal_list, from_nodes_lookup, opts) + # end + + # defp morph_children(from_node, to_node, keyed_removal_list, from_nodes_lookup, opts) do + # skip_from = opts.skip_from_children(from_node, to_node) + + # walk_to_children = fn to_node_child -> + # to_next_sibling = to_node_child.next_sibling + # to_node_key = opts.get_node_key(to_node_child) + + # # walk the from_node children all the way through + # # looks for one that matches the to_node_child + # for from_child <- from_node.child_nodes do + # if GenDOM.Node.is_same_node?(to_node_child, from_child) do + + # end + # end + # end + + # walk_to_children(to_node.first_child) + + # {morphed_node, keyed_removal_list, from_nodes_lookup} + # end + + # defp compare_node_names(from_node, to_node) do + # raise "todo" + # end + + # defp move_children(from_node, to_node) do + # raise "todo" + # to_node + # end + + # defp get_node_key(opts, node) do + # Keyword.get(opts, :get_node_key, fn node -> + # GenDOM.Element.get_attribute(node, "id") + # end).(node) + # end +end diff --git a/test/live_view/morphdom.exs b/test/live_view/morphdom.exs new file mode 100644 index 0000000..a963194 --- /dev/null +++ b/test/live_view/morphdom.exs @@ -0,0 +1,76 @@ +defmodule LiveView.RenderedTest do + use ExUnit.Case, async: true + + describe "morph" do + test "text morph" do + from_document = GenDOM.Parser.parse_from_string("Hello, world!", nil, []) + to_document = GenDOM.Parser.parse_from_string("Goodbye, world!", nil, []) + + from_text = from_document.child_nodes + |> hd() + |> GenDOM.Node.get() + |> Map.get(:child_nodes) + |> hd() + assert(GenDOM.Node.get(from_text).whole_text == "Hello, world!") + + LiveView.MorphDOM.morph(from_document, to_document) + + assert(GenDOM.Node.get(from_text).whole_text == "Goodbye, world!") + end + + test "element attribute morph" do + from_document = GenDOM.Parser.parse_from_string("", nil, []) + to_document = GenDOM.Parser.parse_from_string("", nil, []) + + from_element = hd(from_document.child_nodes) + assert(GenDOM.Node.get(from_element).class_list == []) + + LiveView.MorphDOM.morph(from_document, to_document) + + assert(GenDOM.Node.get(from_element).class_list == ["test-class"]) + end + + test "add child" do + from_document = GenDOM.Parser.parse_from_string("", nil, []) + to_document = GenDOM.Parser.parse_from_string("", nil, []) + + from_element = hd(from_document.child_nodes) + assert(length(GenDOM.Node.get(from_element).child_nodes) == 1) + + LiveView.MorphDOM.morph(from_document, to_document) + + assert(length(GenDOM.Node.get(from_element).child_nodes) == 2) + end + + test "remove child" do + from_document = GenDOM.Parser.parse_from_string("", nil, []) + to_document = GenDOM.Parser.parse_from_string("", nil, []) + + from_element = hd(from_document.child_nodes) + assert(length(GenDOM.Node.get(from_element).child_nodes) == 2) + + LiveView.MorphDOM.morph(from_document, to_document) + + assert(length(GenDOM.Node.get(from_element).child_nodes) == 1) + end + + test "swap child" do + # FIXME: Currently, the morph doesn't support swapping elements that are + # the same. The original morphdom doesn't use a zip of child nodes, but + # walks the to_node and for each sibling it searches the from_node for a + # match. + from_document = GenDOM.Parser.parse_from_string("", nil, []) + to_document = GenDOM.Parser.parse_from_string("", nil, []) + + child_nodes = from_document.child_nodes + |> hd() + |> GenDOM.Node.get() + |> Map.get(:child_nodes) + dbg child_nodes + + LiveView.MorphDOM.morph(from_document, to_document) + + assert(child_nodes == Enum.reverse(child_nodes)) + end + end +end