From a2adddca5887b9e9673ca0a4d1b2a10dde44dc76 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 24 Mar 2026 14:23:31 +0100 Subject: [PATCH 1/9] Herb: Implement Diff Engine --- .gitignore | 3 + ext/herb/extconf.rb | 1 + ext/herb/extension.c | 84 +++ java/herb_jni.c | 79 +++ java/herb_jni.h | 1 + java/org/herb/DiffOperation.java | 50 ++ java/org/herb/DiffResult.java | 34 ++ java/org/herb/Herb.java | 1 + javascript/packages/core/src/backend.ts | 4 + javascript/packages/core/src/diff-result.ts | 15 + javascript/packages/core/src/herb-backend.ts | 14 + javascript/packages/core/src/index.ts | 1 + javascript/packages/node/binding.gyp | 7 + javascript/packages/node/extension/herb.cpp | 104 ++++ lib/herb.rb | 2 + lib/herb/cli.rb | 54 ++ lib/herb/diff_operation.rb | 42 ++ lib/herb/diff_result.rb | 48 ++ playground/index.html | 82 +++ .../src/controllers/playground_controller.js | 434 ++++++++++++++- playground/src/style.css | 131 +++++ rust/src/ffi.rs | 5 +- rust/src/herb.rs | 119 ++++- rust/src/lib.rs | 4 +- sig/herb/diff_operation.rbs | 26 + sig/herb/diff_result.rbs | 25 + src/diff/herb_diff.c | 126 +++++ src/diff/herb_diff_attributes.c | 207 ++++++++ src/diff/herb_diff_children.c | 500 ++++++++++++++++++ src/diff/herb_hash.c | 42 ++ src/diff/herb_hash_map.c | 104 ++++ src/include/diff/herb_diff.h | 118 +++++ src/include/diff/herb_hash.h | 22 + src/include/diff/herb_hash_map.h | 30 ++ src/include/herb.h | 1 + templates/src/diff/herb_diff_helpers.c.erb | 38 ++ templates/src/diff/herb_diff_nodes.c.erb | 224 ++++++++ templates/src/diff/herb_hash_tree.c.erb | 141 +++++ test/c/main.c | 2 + test/c/test_diff.c | 238 +++++++++ test/diff/diff_test.rb | 287 ++++++++++ wasm/herb-wasm.cpp | 69 +++ 42 files changed, 3512 insertions(+), 7 deletions(-) create mode 100644 java/org/herb/DiffOperation.java create mode 100644 java/org/herb/DiffResult.java create mode 100644 javascript/packages/core/src/diff-result.ts create mode 100644 lib/herb/diff_operation.rb create mode 100644 lib/herb/diff_result.rb create mode 100644 sig/herb/diff_operation.rbs create mode 100644 sig/herb/diff_result.rbs create mode 100644 src/diff/herb_diff.c create mode 100644 src/diff/herb_diff_attributes.c create mode 100644 src/diff/herb_diff_children.c create mode 100644 src/diff/herb_hash.c create mode 100644 src/diff/herb_hash_map.c create mode 100644 src/include/diff/herb_diff.h create mode 100644 src/include/diff/herb_hash.h create mode 100644 src/include/diff/herb_hash_map.h create mode 100644 templates/src/diff/herb_diff_helpers.c.erb create mode 100644 templates/src/diff/herb_diff_nodes.c.erb create mode 100644 templates/src/diff/herb_hash_tree.c.erb create mode 100644 test/c/test_diff.c create mode 100644 test/diff/diff_test.rb diff --git a/.gitignore b/.gitignore index cfdcbd0ee..fd6366543 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ src/include/ast/ast_pretty_print.h src/include/errors.h src/include/lib/hb_foreach.h src/parser/match_tags.c +src/diff/herb_diff_helpers.c +src/diff/herb_diff_nodes.c +src/diff/herb_hash_tree.c src/visitor.c wasm/error_helpers.cpp wasm/error_helpers.h diff --git a/ext/herb/extconf.rb b/ext/herb/extconf.rb index 211fb7202..968d560df 100644 --- a/ext/herb/extconf.rb +++ b/ext/herb/extconf.rb @@ -47,6 +47,7 @@ $VPATH << "$(srcdir)/../../src/analyze" $VPATH << "$(srcdir)/../../src/analyze/action_view" $VPATH << "$(srcdir)/../../src/ast" +$VPATH << "$(srcdir)/../../src/diff" $VPATH << "$(srcdir)/../../src/lexer" $VPATH << "$(srcdir)/../../src/location" $VPATH << "$(srcdir)/../../src/parser" diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 8aef9ef92..70914cc9f 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -404,6 +404,89 @@ static VALUE Herb_version(VALUE self) { #endif } +typedef struct { + AST_DOCUMENT_NODE_T* old_root; + AST_DOCUMENT_NODE_T* new_root; + herb_diff_result_T* diff_result; + hb_allocator_T old_allocator; + hb_allocator_T new_allocator; + hb_allocator_T diff_allocator; +} diff_args_T; + +static VALUE rb_create_diff_operation(const herb_diff_operation_T* operation) { + VALUE cDiffOperation = rb_const_get(mHerb, rb_intern("DiffOperation")); + + VALUE type = ID2SYM(rb_intern(herb_diff_operation_type_to_string(operation->type))); + + VALUE path_array = rb_ary_new_capa(operation->path.depth); + for (uint16_t index = 0; index < operation->path.depth; index++) { + rb_ary_push(path_array, UINT2NUM(operation->path.indices[index])); + } + + VALUE old_node = operation->old_node != NULL ? rb_node_from_c_struct((AST_NODE_T*) operation->old_node) : Qnil; + VALUE new_node = operation->new_node != NULL ? rb_node_from_c_struct((AST_NODE_T*) operation->new_node) : Qnil; + + VALUE args[] = { + type, path_array, old_node, new_node, UINT2NUM(operation->old_index), UINT2NUM(operation->new_index) + }; + + return rb_class_new_instance(6, args, cDiffOperation); +} + +static VALUE diff_convert_body(VALUE arg) { + diff_args_T* args = (diff_args_T*) arg; + herb_diff_result_T* diff_result = args->diff_result; + + VALUE cDiffResult = rb_const_get(mHerb, rb_intern("DiffResult")); + + size_t operation_count = herb_diff_operation_count(diff_result); + VALUE operations_array = rb_ary_new_capa((long) operation_count); + + for (size_t index = 0; index < operation_count; index++) { + const herb_diff_operation_T* operation = herb_diff_operation_at(diff_result, index); + rb_ary_push(operations_array, rb_create_diff_operation(operation)); + } + + VALUE result_args[] = { diff_result->trees_identical ? Qtrue : Qfalse, operations_array }; + + return rb_class_new_instance(2, result_args, cDiffResult); +} + +static VALUE diff_cleanup(VALUE arg) { + diff_args_T* args = (diff_args_T*) arg; + + if (args->old_root != NULL) { ast_node_free((AST_NODE_T*) args->old_root, &args->old_allocator); } + if (args->new_root != NULL) { ast_node_free((AST_NODE_T*) args->new_root, &args->new_allocator); } + + hb_allocator_destroy(&args->diff_allocator); + hb_allocator_destroy(&args->old_allocator); + hb_allocator_destroy(&args->new_allocator); + + return Qnil; +} + +static VALUE Herb_diff(int argc, VALUE* argv, VALUE self) { + VALUE old_source, new_source; + rb_scan_args(argc, argv, "2", &old_source, &new_source); + + char* old_string = (char*) check_string(old_source); + char* new_string = (char*) check_string(new_source); + + diff_args_T args = { 0 }; + + parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; + + if (!hb_allocator_init(&args.old_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } + if (!hb_allocator_init(&args.new_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } + if (!hb_allocator_init(&args.diff_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } + + args.old_root = herb_parse(old_string, &parser_options, &args.old_allocator); + args.new_root = herb_parse(new_string, &parser_options, &args.new_allocator); + args.diff_result = herb_diff(args.old_root, args.new_root, &args.diff_allocator); + + return rb_ensure(diff_convert_body, (VALUE) &args, diff_cleanup, (VALUE) &args); +} + __attribute__((__visibility__("default"))) void Init_herb(void) { mHerb = rb_define_module("Herb"); cPosition = rb_define_class_under(mHerb, "Position", rb_cObject); @@ -425,4 +508,5 @@ __attribute__((__visibility__("default"))) void Init_herb(void) { rb_define_singleton_method(mHerb, "arena_stats", Herb_arena_stats, -1); rb_define_singleton_method(mHerb, "leak_check", Herb_leak_check, 1); rb_define_singleton_method(mHerb, "version", Herb_version, 0); + rb_define_singleton_method(mHerb, "diff", Herb_diff, -1); } diff --git a/java/herb_jni.c b/java/herb_jni.c index b60aadbbc..af3923d05 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -1,8 +1,10 @@ #include "herb_jni.h" #include "extension_helpers.h" +#include "nodes.h" #include "../../src/include/extract.h" #include "../../src/include/herb.h" +#include "../../src/include/diff/herb_diff.h" #include "../../src/include/lib/hb_allocator.h" #include "../../src/include/lib/hb_buffer.h" @@ -246,6 +248,83 @@ Java_org_herb_Herb_parseRuby(JNIEnv* env, jclass clazz, jstring source) { return result; } +JNIEXPORT jobject JNICALL +Java_org_herb_Herb_diff(JNIEnv* env, jclass clazz, jstring old_source, jstring new_source) { + const char* old_src = (*env)->GetStringUTFChars(env, old_source, 0); + const char* new_src = (*env)->GetStringUTFChars(env, new_source, 0); + + hb_allocator_T old_allocator; + hb_allocator_T new_allocator; + hb_allocator_T diff_allocator; + + if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA) + || !hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA) + || !hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { + (*env)->ReleaseStringUTFChars(env, old_source, old_src); + (*env)->ReleaseStringUTFChars(env, new_source, new_src); + return NULL; + } + + parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; + + AST_DOCUMENT_NODE_T* old_root = herb_parse(old_src, &parser_options, &old_allocator); + AST_DOCUMENT_NODE_T* new_root = herb_parse(new_src, &parser_options, &new_allocator); + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); + + jclass diff_result_class = (*env)->FindClass(env, "org/herb/DiffResult"); + jclass diff_operation_class = (*env)->FindClass(env, "org/herb/DiffOperation"); + jclass array_list_class = (*env)->FindClass(env, "java/util/ArrayList"); + + jmethodID diff_result_constructor = (*env)->GetMethodID(env, diff_result_class, "", "(ZLjava/util/List;)V"); + jmethodID diff_operation_constructor = (*env)->GetMethodID(env, diff_operation_class, "", "(Ljava/lang/String;[ILjava/lang/Object;Ljava/lang/Object;II)V"); + jmethodID array_list_constructor = (*env)->GetMethodID(env, array_list_class, "", "()V"); + jmethodID array_list_add = (*env)->GetMethodID(env, array_list_class, "add", "(Ljava/lang/Object;)Z"); + + jobject operations_list = (*env)->NewObject(env, array_list_class, array_list_constructor); + + size_t operation_count = herb_diff_operation_count(diff_result); + + for (size_t index = 0; index < operation_count; index++) { + const herb_diff_operation_T* operation = herb_diff_operation_at(diff_result, index); + + jstring type_string = (*env)->NewStringUTF(env, herb_diff_operation_type_to_string(operation->type)); + jintArray path_array = (*env)->NewIntArray(env, operation->path.depth); + jint* path_elements = (*env)->GetIntArrayElements(env, path_array, NULL); + + for (uint16_t path_index = 0; path_index < operation->path.depth; path_index++) { + path_elements[path_index] = (jint) operation->path.indices[path_index]; + } + + (*env)->ReleaseIntArrayElements(env, path_array, path_elements, 0); + + jobject old_node = operation->old_node != NULL ? CreateASTNode(env, (AST_NODE_T*) operation->old_node) : NULL; + jobject new_node = operation->new_node != NULL ? CreateASTNode(env, (AST_NODE_T*) operation->new_node) : NULL; + + jobject diff_operation = (*env)->NewObject( + env, diff_operation_class, diff_operation_constructor, + type_string, path_array, old_node, new_node, + (jint) operation->old_index, (jint) operation->new_index + ); + + (*env)->CallBooleanMethod(env, operations_list, array_list_add, diff_operation); + } + + jboolean identical = herb_diff_trees_identical(diff_result) ? JNI_TRUE : JNI_FALSE; + jobject result = (*env)->NewObject(env, diff_result_class, diff_result_constructor, identical, operations_list); + + ast_node_free((AST_NODE_T*) old_root, &old_allocator); + ast_node_free((AST_NODE_T*) new_root, &new_allocator); + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + (*env)->ReleaseStringUTFChars(env, old_source, old_src); + (*env)->ReleaseStringUTFChars(env, new_source, new_src); + + return result; +} + JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractHTML(JNIEnv* env, jclass clazz, jstring source) { const char* src = (*env)->GetStringUTFChars(env, source, 0); diff --git a/java/herb_jni.h b/java/herb_jni.h index b4c0f593d..28be2828c 100644 --- a/java/herb_jni.h +++ b/java/herb_jni.h @@ -14,6 +14,7 @@ JNIEXPORT jobject JNICALL Java_org_herb_Herb_lex(JNIEnv*, jclass, jstring); JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractRuby(JNIEnv*, jclass, jstring, jobject); JNIEXPORT jstring JNICALL Java_org_herb_Herb_extractHTML(JNIEnv*, jclass, jstring); JNIEXPORT jbyteArray JNICALL Java_org_herb_Herb_parseRuby(JNIEnv*, jclass, jstring); +JNIEXPORT jobject JNICALL Java_org_herb_Herb_diff(JNIEnv*, jclass, jstring, jstring); #ifdef __cplusplus } diff --git a/java/org/herb/DiffOperation.java b/java/org/herb/DiffOperation.java new file mode 100644 index 000000000..57085135a --- /dev/null +++ b/java/org/herb/DiffOperation.java @@ -0,0 +1,50 @@ +package org.herb; + +import java.util.Arrays; + +public class DiffOperation { + private final String type; + private final int[] path; + private final Object oldNode; + private final Object newNode; + private final int oldIndex; + private final int newIndex; + + public DiffOperation(String type, int[] path, Object oldNode, Object newNode, int oldIndex, int newIndex) { + this.type = type; + this.path = path; + this.oldNode = oldNode; + this.newNode = newNode; + this.oldIndex = oldIndex; + this.newIndex = newIndex; + } + + public String getType() { + return type; + } + + public int[] getPath() { + return path; + } + + public Object getOldNode() { + return oldNode; + } + + public Object getNewNode() { + return newNode; + } + + public int getOldIndex() { + return oldIndex; + } + + public int getNewIndex() { + return newIndex; + } + + @Override + public String toString() { + return String.format("DiffOperation{type=%s, path=%s}", type, Arrays.toString(path)); + } +} diff --git a/java/org/herb/DiffResult.java b/java/org/herb/DiffResult.java new file mode 100644 index 000000000..c67986eef --- /dev/null +++ b/java/org/herb/DiffResult.java @@ -0,0 +1,34 @@ +package org.herb; + +import java.util.List; + +public class DiffResult { + private final boolean identical; + private final List operations; + + public DiffResult(boolean identical, List operations) { + this.identical = identical; + this.operations = operations; + } + + public boolean isIdentical() { + return identical; + } + + public List getOperations() { + return operations; + } + + public int getOperationCount() { + return operations.size(); + } + + @Override + public String toString() { + if (identical) { + return "DiffResult{identical=true}"; + } + + return String.format("DiffResult{identical=false, operations=%d}", operations.size()); + } +} diff --git a/java/org/herb/Herb.java b/java/org/herb/Herb.java index 5a37fd7f1..d67f9ed45 100644 --- a/java/org/herb/Herb.java +++ b/java/org/herb/Herb.java @@ -21,6 +21,7 @@ public class Herb { public static native String extractRuby(String source, ExtractRubyOptions options); public static native String extractHTML(String source); public static native byte[] parseRuby(String source); + public static native DiffResult diff(String oldSource, String newSource); public static ParseResult parse(String source) { return parse(source, null); diff --git a/javascript/packages/core/src/backend.ts b/javascript/packages/core/src/backend.ts index ecda7f1c7..0bb2c6fb8 100644 --- a/javascript/packages/core/src/backend.ts +++ b/javascript/packages/core/src/backend.ts @@ -2,12 +2,15 @@ import type { SerializedParseResult } from "./parse-result.js" import type { SerializedLexResult } from "./lex-result.js" import type { ParseOptions } from "./parser-options.js" import type { ExtractRubyOptions } from "./extract-ruby-options.js" +import type { DiffResult } from "./diff-result.js" interface LibHerbBackendFunctions { lex: (source: string) => SerializedLexResult parse: (source: string, options?: ParseOptions) => SerializedParseResult + diff: (oldSource: string, newSource: string) => DiffResult + extractRuby: (source: string, options?: ExtractRubyOptions) => string extractHTML: (source: string) => string @@ -21,6 +24,7 @@ export type BackendPromise = () => Promise const expectedFunctions = [ "parse", "lex", + "diff", "extractRuby", "extractHTML", "parseRuby", diff --git a/javascript/packages/core/src/diff-result.ts b/javascript/packages/core/src/diff-result.ts new file mode 100644 index 000000000..5524c1e9c --- /dev/null +++ b/javascript/packages/core/src/diff-result.ts @@ -0,0 +1,15 @@ +import type { SerializedNode } from "./nodes/index.js" + +export interface DiffOperation { + type: string + path: number[] + oldNode: SerializedNode | null + newNode: SerializedNode | null + oldIndex: number + newIndex: number +} + +export interface DiffResult { + identical: boolean + operations: DiffOperation[] +} diff --git a/javascript/packages/core/src/herb-backend.ts b/javascript/packages/core/src/herb-backend.ts index 630108906..57e6f5414 100644 --- a/javascript/packages/core/src/herb-backend.ts +++ b/javascript/packages/core/src/herb-backend.ts @@ -11,6 +11,7 @@ import type { LibHerbBackend, BackendPromise } from "./backend.js" import type { ParseOptions } from "./parser-options.js" import type { ExtractRubyOptions } from "./extract-ruby-options.js" import type { PrismParseResult } from "./prism/index.js" +import type { DiffResult } from "./diff-result.js" /** * The main Herb parser interface, providing methods to lex and parse input. @@ -117,6 +118,19 @@ export abstract class HerbBackend { return deserializePrismParseResult(bytes, source) } + /** + * Diffs two source strings and returns the minimal set of AST differences. + * @param oldSource - The old source code. + * @param newSource - The new source code. + * @returns A DiffResult containing the operations. + * @throws Error if the backend is not loaded. + */ + diff(oldSource: string, newSource: string): DiffResult { + this.ensureBackend() + + return this.backend.diff(ensureString(oldSource), ensureString(newSource)) + } + /** * Extracts HTML from the given source. * @param source - The source code to extract HTML from. diff --git a/javascript/packages/core/src/index.ts b/javascript/packages/core/src/index.ts index 1878a3fe4..099154281 100644 --- a/javascript/packages/core/src/index.ts +++ b/javascript/packages/core/src/index.ts @@ -2,6 +2,7 @@ export * from "./ast-utils.js" export * from "./html-constants.js" export * from "./html-character-references.js" export * from "./backend.js" +export * from "./diff-result.js" export * from "./diagnostic.js" export * from "./didyoumean.js" export * from "./errors.js" diff --git a/javascript/packages/node/binding.gyp b/javascript/packages/node/binding.gyp index 48f52fa38..780ba6d88 100644 --- a/javascript/packages/node/binding.gyp +++ b/javascript/packages/node/binding.gyp @@ -35,6 +35,13 @@ "./extension/libherb/analyze/action_view/tag_helpers.c", "./extension/libherb/analyze/action_view/tag.c", "./extension/libherb/analyze/action_view/turbo_frame_tag.c", + "./extension/libherb/diff/herb_diff.c", + "./extension/libherb/diff/herb_diff_attributes.c", + "./extension/libherb/diff/herb_diff_children.c", + "./extension/libherb/diff/herb_diff_nodes.c", + "./extension/libherb/diff/herb_hash.c", + "./extension/libherb/diff/herb_hash_map.c", + "./extension/libherb/diff/herb_hash_tree.c", "./extension/libherb/ast/ast_node.c", "./extension/libherb/ast/ast_nodes.c", "./extension/libherb/ast/ast_pretty_print.c", diff --git a/javascript/packages/node/extension/herb.cpp b/javascript/packages/node/extension/herb.cpp index d4451dfc4..c314f398d 100644 --- a/javascript/packages/node/extension/herb.cpp +++ b/javascript/packages/node/extension/herb.cpp @@ -2,6 +2,7 @@ extern "C" { #include "../extension/libherb/include/ast/ast_nodes.h" #include "../extension/libherb/include/extract.h" #include "../extension/libherb/include/herb.h" +#include "../extension/libherb/include/diff/herb_diff.h" #include "../extension/libherb/include/location/location.h" #include "../extension/libherb/include/location/range.h" #include "../extension/libherb/include/lexer/token.h" @@ -357,12 +358,115 @@ napi_value Herb_version(napi_env env, napi_callback_info info) { return result; } +napi_value Herb_diff(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc < 2) { + napi_throw_error(env, nullptr, "Wrong number of arguments: expected 2 (old_source, new_source)"); + return nullptr; + } + + char* old_string = CheckString(env, args[0]); + if (!old_string) { return nullptr; } + + char* new_string = CheckString(env, args[1]); + if (!new_string) { free(old_string); return nullptr; } + + hb_allocator_T old_allocator; + hb_allocator_T new_allocator; + hb_allocator_T diff_allocator; + + if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA)) { free(old_string); free(new_string); return nullptr; } + if (!hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA)) { free(old_string); free(new_string); hb_allocator_destroy(&old_allocator); return nullptr; } + if (!hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { free(old_string); free(new_string); hb_allocator_destroy(&old_allocator); hb_allocator_destroy(&new_allocator); return nullptr; } + + parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; + + AST_DOCUMENT_NODE_T* old_root = herb_parse(old_string, &parser_options, &old_allocator); + AST_DOCUMENT_NODE_T* new_root = herb_parse(new_string, &parser_options, &new_allocator); + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); + + napi_value result; + napi_create_object(env, &result); + + napi_value identical; + napi_get_boolean(env, diff_result->trees_identical, &identical); + napi_set_named_property(env, result, "identical", identical); + + size_t operation_count = herb_diff_operation_count(diff_result); + + napi_value operations; + napi_create_array_with_length(env, operation_count, &operations); + + for (size_t index = 0; index < operation_count; index++) { + const herb_diff_operation_T* operation = herb_diff_operation_at(diff_result, index); + + napi_value operation_object; + napi_value type_val; + napi_value path; + + napi_create_object(env, &operation_object); + napi_create_string_utf8(env, herb_diff_operation_type_to_string(operation->type), NAPI_AUTO_LENGTH, &type_val); + napi_set_named_property(env, operation_object, "type", type_val); + napi_create_array_with_length(env, operation->path.depth, &path); + + for (uint16_t path_index = 0; path_index < operation->path.depth; path_index++) { + napi_value path_val; + napi_create_uint32(env, operation->path.indices[path_index], &path_val); + napi_set_element(env, path, path_index, path_val); + } + + napi_set_named_property(env, operation_object, "path", path); + + if (operation->old_node != NULL) { + napi_set_named_property(env, operation_object, "oldNode", NodeFromCStruct(env, (AST_NODE_T*) operation->old_node)); + } else { + napi_value null_val; + napi_get_null(env, &null_val); + napi_set_named_property(env, operation_object, "oldNode", null_val); + } + + if (operation->new_node != NULL) { + napi_set_named_property(env, operation_object, "newNode", NodeFromCStruct(env, (AST_NODE_T*) operation->new_node)); + } else { + napi_value null_val; + napi_get_null(env, &null_val); + napi_set_named_property(env, operation_object, "newNode", null_val); + } + + napi_value old_index_val, new_index_val; + napi_create_uint32(env, operation->old_index, &old_index_val); + napi_create_uint32(env, operation->new_index, &new_index_val); + napi_set_named_property(env, operation_object, "oldIndex", old_index_val); + napi_set_named_property(env, operation_object, "newIndex", new_index_val); + + napi_set_element(env, operations, (uint32_t) index, operation_object); + } + + napi_set_named_property(env, result, "operations", operations); + + ast_node_free((AST_NODE_T*) old_root, &old_allocator); + ast_node_free((AST_NODE_T*) new_root, &new_allocator); + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + free(old_string); + free(new_string); + + return result; +} + napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor descriptors[] = { { "parse", nullptr, Herb_parse, nullptr, nullptr, nullptr, napi_default, nullptr }, { "lex", nullptr, Herb_lex, nullptr, nullptr, nullptr, napi_default, nullptr }, { "extractRuby", nullptr, Herb_extract_ruby, nullptr, nullptr, nullptr, napi_default, nullptr }, { "extractHTML", nullptr, Herb_extract_html, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "diff", nullptr, Herb_diff, nullptr, nullptr, nullptr, napi_default, nullptr }, { "version", nullptr, Herb_version, nullptr, nullptr, nullptr, napi_default, nullptr }, { "parseRuby", nullptr, Herb_parse_ruby, nullptr, nullptr, nullptr, napi_default, nullptr }, }; diff --git a/lib/herb.rb b/lib/herb.rb index ba5eaebe6..f012aa5a0 100644 --- a/lib/herb.rb +++ b/lib/herb.rb @@ -13,6 +13,8 @@ require_relative "herb/lex_result" require_relative "herb/parser_options" require_relative "herb/parse_result" +require_relative "herb/diff_operation" +require_relative "herb/diff_result" require_relative "herb/ast" require_relative "herb/ast/node" diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index 50ea35c3d..a1b1eabd6 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -99,6 +99,7 @@ def help(exit_code = 0) bundle exec herb config [path] Show configuration and file patterns for a project bundle exec herb ruby [file] Extract Ruby from a file. bundle exec herb html [file] Extract HTML from a file. + bundle exec herb diff [old] [new] Diff two files and show the minimal set of AST differences. bundle exec herb playground [file] Open the content of the source file in the playground bundle exec herb version Prints the versions of the Herb gem and the libherb library. @@ -197,6 +198,8 @@ def result system(%(open "#{url}##{hash}")) exit(0) end + when "diff" + diff_files when "lint" run_node_tool("herb-lint", "@herb-tools/linter") when "format" @@ -435,6 +438,57 @@ def generate_report project.print_file_report(@file) end + def diff_files + old_file = @args[1] + new_file = @args[2] + + if old_file.nil? || new_file.nil? + puts "Usage: herb diff [options]" + exit(1) + end + + unless File.exist?(old_file) + puts "File doesn't exist: #{old_file}" + exit(1) + end + + unless File.exist?(new_file) + puts "File doesn't exist: #{new_file}" + exit(1) + end + + old_content = File.read(old_file) + new_content = File.read(new_file) + + diff_result = Herb.diff(old_content, new_content) + + if json + require "json" + puts JSON.pretty_generate(diff_result.to_hash) + elsif diff_result.identical? + puts "Trees are identical." + else + operations = diff_result.operations + puts "#{operations.size} difference#{"s" unless operations.size == 1} found:\n\n" + + operations.each_with_index do |operation, index| + puts " #{index + 1}. #{operation.type} at path [#{operation.path.join(", ")}]" + + if operation.old_node + puts " old: #{operation.old_node.type}" + end + + if operation.new_node + puts " new: #{operation.new_node.type}" + end + + puts + end + end + + exit(0) + end + def compile_template require_relative "engine" diff --git a/lib/herb/diff_operation.rb b/lib/herb/diff_operation.rb new file mode 100644 index 000000000..8b1a9dc84 --- /dev/null +++ b/lib/herb/diff_operation.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# typed: true + +module Herb + class DiffOperation + attr_reader :type #: Symbol + attr_reader :path #: Array[Integer] + attr_reader :old_node #: Herb::AST::Node? + attr_reader :new_node #: Herb::AST::Node? + attr_reader :old_index #: Integer + attr_reader :new_index #: Integer + + #: (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void + def initialize(type, path, old_node, new_node, old_index, new_index) + @type = type + @path = path + @old_node = old_node + @new_node = new_node + @old_index = old_index + @new_index = new_index + end + + #: () -> Hash[Symbol, untyped] + def to_hash + { + type: type, + path: path, + old_node: old_node, + new_node: new_node, + old_index: old_index, + new_index: new_index, + } + end + + alias to_h to_hash + + #: () -> String + def inspect + "#<#{self.class.name} type=#{type} path=[#{path.join(", ")}]>" + end + end +end diff --git a/lib/herb/diff_result.rb b/lib/herb/diff_result.rb new file mode 100644 index 000000000..2f0ca9a4c --- /dev/null +++ b/lib/herb/diff_result.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# typed: true + +module Herb + class DiffResult + attr_reader :operations #: Array[Herb::DiffOperation] + + #: (bool, Array[Herb::DiffOperation]) -> void + def initialize(identical, operations) + @identical = identical + @operations = operations + end + + #: () -> bool + def identical? + @identical + end + + #: () -> Integer + def operation_count + operations.size + end + + #: () -> bool + def changed? + !identical? + end + + #: () -> Hash[Symbol, untyped] + def to_hash + { + identical: identical?, + operations: operations.map(&:to_hash), + } + end + + alias to_h to_hash + + #: () -> String + def inspect + if identical? + "#<#{self.class.name} identical>" + else + "#<#{self.class.name} #{operation_count} operation#{"s" unless operation_count == 1}>" + end + end + end +end diff --git a/playground/index.html b/playground/index.html index 19450986b..2a67573ee 100644 --- a/playground/index.html +++ b/playground/index.html @@ -406,6 +406,16 @@ Rewrite + + + + + + +
+ + + + + + + +
+ + +
+
+ + +
+ +
50) { + this.diffFeedEntries = this.diffFeedEntries.slice(0, 50) + } + } + + this.renderDiffFeed(result) + this.previousSource = value + } catch (error) { + console.error("Diff error:", error) + } + } + + renderDiffFeed(latestResult) { + if (!this.hasDiffOutputTarget) return + + if (!this.diffFeedEntries || this.diffFeedEntries.length === 0) { + if (latestResult && latestResult.identical) { + this.diffOutputTarget.innerHTML = 'No changes detected.' + this.updateDiffStatus("Identical") + } + + return + } + + const totalOperations = this.diffFeedEntries.reduce((sum, entry) => sum + entry.operations.length, 0) + this.updateDiffStatus(`${totalOperations} change${totalOperations === 1 ? "" : "s"} in ${this.diffFeedEntries.length} edit${this.diffFeedEntries.length === 1 ? "" : "s"}`) + + let html = "" + + this.diffFeedEntries.forEach((entry, entryIndex) => { + const time = entry.timestamp.toLocaleTimeString() + const isCurrent = entryIndex === 0 + + html += `
` + html += `
` + html += `${isCurrent ? "Latest" : time}` + html += `${entry.operations.length} operation${entry.operations.length === 1 ? "" : "s"}` + + if (!isCurrent && entry.source) { + html += `` + } else if (isCurrent && entry.previousSource) { + html += `` + } + + html += `
` + html += this.renderOperations(entry.operations) + html += `
` + }) + + this.diffOutputTarget.innerHTML = html + this.bindDiffRollbackButtons() + } + + bindDiffRollbackButtons() { + if (!this.hasDiffOutputTarget) return + + this.diffOutputTarget.querySelectorAll("[data-diff-rollback-index]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault() + const entryIndex = parseInt(button.dataset.diffRollbackIndex) + this.diffRollbackTo(entryIndex) + }) + }) + + this.diffOutputTarget.querySelectorAll("[data-diff-undo-index]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault() + const entryIndex = parseInt(button.dataset.diffUndoIndex) + this.diffUndo(entryIndex) + }) + }) + } + + diffRollbackTo(entryIndex) { + const entry = this.diffFeedEntries[entryIndex] + if (!entry || !entry.source) return + + this.diffFeedEntries = this.diffFeedEntries.slice(entryIndex) + this.previousSource = entry.source + + if (this.editor) { + this.editor.setValue(entry.source) + } else { + this.inputTarget.value = entry.source + } + + this.analyze() + } + + diffUndo(entryIndex) { + const entry = this.diffFeedEntries[entryIndex] + if (!entry || !entry.previousSource) return + + this.diffFeedEntries.shift() + this.previousSource = entry.previousSource + + if (this.editor) { + this.editor.setValue(entry.previousSource) + } else { + this.inputTarget.value = entry.previousSource + } + + this.analyze() + } + + renderDiffResult(result) { + if (!this.hasDiffOutputTarget) return + + if (result.identical) { + this.diffOutputTarget.innerHTML = 'Trees are identical - no differences found.' + this.updateDiffStatus("Identical") + return + } + + const operations = result.operations + this.updateDiffStatus(`${operations.length} difference${operations.length === 1 ? "" : "s"}`) + this.diffOutputTarget.innerHTML = this.renderOperations(operations) + } + + renderOperations(operations) { + const typeStyles = { + node_inserted: { css: "inserted", icon: "fa-plus" }, + node_removed: { css: "removed", icon: "fa-minus" }, + node_replaced: { css: "replaced", icon: "fa-right-left" }, + text_changed: { css: "changed", icon: "fa-pen" }, + erb_content_changed: { css: "erb", icon: "fa-code" }, + attribute_added: { css: "attribute", icon: "fa-plus" }, + attribute_removed: { css: "removed", icon: "fa-minus" }, + attribute_value_changed:{ css: "attribute", icon: "fa-pen" }, + tag_name_changed: { css: "tag", icon: "fa-tag" }, + node_moved: { css: "moved", icon: "fa-arrows-alt" }, + } + + let html = "" + + operations.forEach((operation, index) => { + const style = typeStyles[operation.type] || { css: "changed", icon: "fa-circle" } + const typeLabel = operation.type.replace(/_/g, " ") + + html += `
` + html += `
` + html += `#${index + 1}` + html += `` + html += `${typeLabel}` + html += `[${operation.path.join(", ")}]` + html += `
` + + const oldNode = operation.oldNode || operation.old_node + const newNode = operation.newNode || operation.new_node + + if (oldNode) { + html += `
- ${oldNode.type}` + + if (oldNode.location) { + html += ` (${oldNode.location.start.line}:${oldNode.location.start.column})` + } + + html += `
` + + const oldValue = this.extractNodeValue(oldNode, operation.type) + if (oldValue !== null) { + html += `
${this.escapeHtml(oldValue)}
` + } + } + + if (newNode) { + html += `
+ ${newNode.type}` + + if (newNode.location) { + html += ` (${newNode.location.start.line}:${newNode.location.start.column})` + } + + html += `
` + + const newValue = this.extractNodeValue(newNode, operation.type) + + if (newValue !== null) { + html += `
${this.escapeHtml(newValue)}
` + } + } + + html += `
` + }) + + return html + } + + extractNodeValue(node, operationType) { + if (!node) return null + + if (operationType === "text_changed" || node.type === "AST_HTML_TEXT_NODE") { + return node.content || null + } + + if (operationType === "erb_content_changed" || node.type === "AST_ERB_CONTENT_NODE") { + if (node.content && node.content.value) { + return node.content.value + } + + return null + } + + if (operationType === "attribute_value_changed" || operationType === "attribute_added" || operationType === "attribute_removed") { + if (node.type === "AST_HTML_ATTRIBUTE_NODE") { + let result = "" + + if (node.name && node.name.children) { + const nameParts = node.name.children.map(child => child.content || child.value || "").join("") + result += nameParts + } + + if (node.value && node.value.children) { + const valueParts = node.value.children.map(child => child.content || child.value || "").join("") + result += `="${valueParts}"` + } + + return result || null + } + } + + if (node.type === "AST_HTML_ELEMENT_NODE" || node.type === "AST_HTML_CONDITIONAL_ELEMENT_NODE") { + if (node.tag_name && node.tag_name.value) { + return `<${node.tag_name.value}>` + } + + return null + } + + if (node.type === "AST_LITERAL_NODE" || node.type === "AST_RUBY_LITERAL_NODE") { + return node.content || null + } + + return null + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } + + updateDiffStatus(text) { + if (this.hasDiffStatusTarget) { + this.diffStatusTarget.className = "px-2 py-1 text-xs rounded font-mono font-medium" + + if (text.includes("Identical") || text.includes("Cleared")) { + this.diffStatusTarget.style.color = "#90b874" + this.diffStatusTarget.style.background = "rgba(144, 184, 116, 0.15)" + } else if (text.includes("change") || text.includes("difference")) { + this.diffStatusTarget.style.color = "#e5c07b" + this.diffStatusTarget.style.background = "rgba(229, 192, 123, 0.15)" + } else { + this.diffStatusTarget.style.color = "#abb2bf" + this.diffStatusTarget.style.background = "rgba(171, 178, 191, 0.1)" + } + + this.diffStatusTarget.textContent = text + } + } + + showDiffParseError() { + if (this.hasDiffParseErrorTarget) { + this.diffParseErrorTarget.classList.remove("hidden") + } + } + + hideDiffParseError() { + if (this.hasDiffParseErrorTarget) { + this.diffParseErrorTarget.classList.add("hidden") + } + } + clearTreeLocationHighlights() { this.parseOutputTarget .querySelectorAll(".tree-location-highlight") @@ -1175,6 +1605,8 @@ export default class extends Controller { this.updateDiagnosticsFilterButtons(this.currentDiagnosticsFilter) this.updateDiagnosticsViewer(this.getFilteredDiagnostics()) } + + this.updateDiff(!hasParserErrors) } async analyzeRuby(value) { diff --git a/playground/src/style.css b/playground/src/style.css index 02c0a50ac..666e39991 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -229,6 +229,137 @@ code.language-tree { border-radius: 2px; } +/* Diff operation cards */ +.diff-operation { + margin-bottom: 0.5rem; + padding: 0.5rem 0.625rem; + border-radius: 4px; + border-left: 3px solid; + background: rgba(255, 255, 255, 0.03); +} + +.diff-operation:hover { + background: rgba(255, 255, 255, 0.06); +} + +.diff-op-inserted { border-left-color: #90b874; } +.diff-op-removed { border-left-color: #e06c75; } +.diff-op-replaced { border-left-color: #d19a66; } +.diff-op-changed { border-left-color: #e5c07b; } +.diff-op-erb { border-left-color: #c678dd; } +.diff-op-attribute { border-left-color: #61afef; } +.diff-op-tag { border-left-color: #d19a66; } +.diff-op-moved { border-left-color: #56b6c2; } + +.diff-label-inserted { color: #90b874; } +.diff-label-removed { color: #e06c75; } +.diff-label-replaced { color: #d19a66; } +.diff-label-changed { color: #e5c07b; } +.diff-label-erb { color: #c678dd; } +.diff-label-attribute { color: #61afef; } +.diff-label-tag { color: #d19a66; } +.diff-label-moved { color: #56b6c2; } + +.diff-value-old { + color: #e06c75; + background: rgba(224, 108, 117, 0.1); + padding: 1px 6px; + border-radius: 3px; + display: inline-block; + margin-top: 2px; + margin-left: 1rem; +} + +.diff-value-new { + color: #90b874; + background: rgba(144, 184, 116, 0.1); + padding: 1px 6px; + border-radius: 3px; + display: inline-block; + margin-top: 2px; + margin-left: 1rem; +} + +.diff-path { + color: #607d8b; +} + +.diff-index { + color: #607d8b; +} + +.diff-node-type { + color: #abb2bf; +} + +.diff-location { + color: #607d8b; +} + +.diff-feed-header { + color: #abb2bf; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 0.25rem; + margin-bottom: 0.5rem; +} + +.diff-feed-current .diff-feed-header { + color: #e5c07b; +} + +.diff-feed-current { + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + border-bottom: 2px solid rgba(255, 255, 255, 0.1); +} + +.diff-feed-past { + opacity: 0.5; +} + +.diff-feed-past:hover { + opacity: 0.75; +} + +.diff-empty { + color: #607d8b; +} + +.diff-rollback-button { + color: #abb2bf; + background: rgba(171, 178, 191, 0.1); + padding: 1px 8px; + border-radius: 3px; + border: none; + cursor: pointer; + font-size: 11px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + transition: color 0.15s, background 0.15s; +} + +.diff-rollback-button:hover { + color: #e5c07b; + background: rgba(229, 192, 123, 0.15); +} + +.diff-rollback-button[title] { + cursor: pointer; +} + +.diff-rollback-button[title]:hover::after { + bottom: auto; + top: 100%; + margin-top: 5px; + margin-bottom: 0; +} + +.diff-rollback-button[title]:hover::before { + bottom: auto; + top: 100%; + border-top-color: transparent; + border-bottom-color: #333; +} + /* Tooltip styles */ [title] { position: relative; diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs index 16bef5cfc..05a1f6c7c 100644 --- a/rust/src/ffi.rs +++ b/rust/src/ffi.rs @@ -1,5 +1,6 @@ pub use crate::bindings::{ ast_node_free, hb_allocator_T, hb_allocator_destroy, hb_allocator_init, hb_array_get, hb_array_size, hb_buffer_free, hb_buffer_init, hb_buffer_value, - hb_string_T, herb_extract, herb_extract_ruby_to_buffer_with_options, herb_free_ruby_parse_result, herb_free_tokens, herb_lex, herb_parse, herb_parse_ruby, - herb_prism_version, herb_version, pm_buffer_free, pm_buffer_t, pm_prettyprint, token_type_to_string, HB_ALLOCATOR_ARENA, + hb_string_T, herb_diff, herb_diff_operation_at, herb_diff_operation_count, herb_diff_operation_type_to_string, herb_diff_trees_identical, herb_extract, + herb_extract_ruby_to_buffer_with_options, herb_free_ruby_parse_result, herb_free_tokens, herb_lex, herb_parse, herb_parse_ruby, herb_prism_version, + herb_version, pm_buffer_free, pm_buffer_t, pm_prettyprint, token_type_to_string, HB_ALLOCATOR_ARENA, }; diff --git a/rust/src/herb.rs b/rust/src/herb.rs index fec43d0fa..7f8bdc622 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -1,7 +1,7 @@ -use crate::bindings::{hb_array_T, hb_buffer_T, token_T}; +use crate::bindings::{hb_array_T, hb_buffer_T, token_T, AST_NODE_T}; use crate::convert::token_from_c; use crate::{LexResult, ParseResult}; -use std::ffi::CString; +use std::ffi::{CStr, CString}; #[derive(Debug, Clone)] pub struct ParserOptions { @@ -281,3 +281,118 @@ pub fn version() -> String { herb_version() ) } + +#[derive(Debug, Clone)] +pub struct DiffOperation { + pub operation_type: String, + pub path: Vec, + pub old_node: Option, + pub new_node: Option, + pub old_index: u32, + pub new_index: u32, +} + +#[derive(Debug, Clone)] +pub struct DiffResult { + pub identical: bool, + pub operations: Vec, +} + +pub fn diff(old_source: &str, new_source: &str) -> Result { + unsafe { + let old_c_source = CString::new(old_source).map_err(|error| error.to_string())?; + let new_c_source = CString::new(new_source).map_err(|error| error.to_string())?; + + let mut old_allocator: crate::ffi::hb_allocator_T = std::mem::zeroed(); + let mut new_allocator: crate::ffi::hb_allocator_T = std::mem::zeroed(); + let mut diff_allocator: crate::ffi::hb_allocator_T = std::mem::zeroed(); + + if !crate::ffi::hb_allocator_init(&mut old_allocator, crate::ffi::HB_ALLOCATOR_ARENA) { + return Err("Failed to initialize old allocator".to_string()); + } + + if !crate::ffi::hb_allocator_init(&mut new_allocator, crate::ffi::HB_ALLOCATOR_ARENA) { + crate::ffi::hb_allocator_destroy(&mut old_allocator); + return Err("Failed to initialize new allocator".to_string()); + } + + if !crate::ffi::hb_allocator_init(&mut diff_allocator, crate::ffi::HB_ALLOCATOR_ARENA) { + crate::ffi::hb_allocator_destroy(&mut old_allocator); + crate::ffi::hb_allocator_destroy(&mut new_allocator); + return Err("Failed to initialize diff allocator".to_string()); + } + + let parser_options = crate::bindings::parser_options_T { + track_whitespace: false, + analyze: true, + strict: true, + action_view_helpers: false, + render_nodes: false, + strict_locals: false, + prism_program: false, + prism_nodes: false, + prism_nodes_deep: false, + dot_notation_tags: false, + html: true, + start_line: 0, + start_column: 0, + }; + + let old_root = crate::ffi::herb_parse(old_c_source.as_ptr(), &parser_options, &mut old_allocator); + let new_root = crate::ffi::herb_parse(new_c_source.as_ptr(), &parser_options, &mut new_allocator); + + let diff_result = crate::ffi::herb_diff(old_root, new_root, &mut diff_allocator); + + let identical = crate::ffi::herb_diff_trees_identical(diff_result); + let operation_count = crate::ffi::herb_diff_operation_count(diff_result); + + let mut operations = Vec::with_capacity(operation_count); + + for index in 0..operation_count { + let operation = crate::ffi::herb_diff_operation_at(diff_result, index); + + if operation.is_null() { + continue; + } + + let operation_ref = &*operation; + + let type_c_str = CStr::from_ptr(crate::ffi::herb_diff_operation_type_to_string(operation_ref.type_)); + let operation_type = type_c_str.to_string_lossy().into_owned(); + + let mut path = Vec::with_capacity(operation_ref.path.depth as usize); + for path_index in 0..operation_ref.path.depth { + path.push(operation_ref.path.indices[path_index as usize]); + } + + let old_node = if !operation_ref.old_node.is_null() { + crate::ast::nodes::convert_node(operation_ref.old_node as *const std::ffi::c_void) + } else { + None + }; + + let new_node = if !operation_ref.new_node.is_null() { + crate::ast::nodes::convert_node(operation_ref.new_node as *const std::ffi::c_void) + } else { + None + }; + + operations.push(DiffOperation { + operation_type, + path, + old_node, + new_node, + old_index: operation_ref.old_index, + new_index: operation_ref.new_index, + }); + } + + crate::ffi::ast_node_free(old_root as *mut AST_NODE_T, &mut old_allocator); + crate::ffi::ast_node_free(new_root as *mut AST_NODE_T, &mut new_allocator); + crate::ffi::hb_allocator_destroy(&mut diff_allocator); + crate::ffi::hb_allocator_destroy(&mut old_allocator); + crate::ffi::hb_allocator_destroy(&mut new_allocator); + + Ok(DiffResult { identical, operations }) + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1654fb392..220cf8c15 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -16,8 +16,8 @@ pub mod visitor; pub use errors::{AnyError, ErrorNode, ErrorType}; pub use herb::{ - extract_html, extract_ruby, extract_ruby_with_options, herb_version, lex, parse, parse_ruby, parse_with_options, prism_version, version, ExtractRubyOptions, - ParserOptions, RubyParseResult, + diff, extract_html, extract_ruby, extract_ruby_with_options, herb_version, lex, parse, parse_ruby, parse_with_options, prism_version, version, DiffOperation, + DiffResult, ExtractRubyOptions, ParserOptions, RubyParseResult, }; pub use lex_result::LexResult; pub use location::Location; diff --git a/sig/herb/diff_operation.rbs b/sig/herb/diff_operation.rbs new file mode 100644 index 000000000..36a0c13ed --- /dev/null +++ b/sig/herb/diff_operation.rbs @@ -0,0 +1,26 @@ +# Generated from lib/herb/diff_operation.rb with RBS::Inline + +module Herb + class DiffOperation + attr_reader type: Symbol + + attr_reader path: Array[Integer] + + attr_reader old_node: Herb::AST::Node? + + attr_reader new_node: Herb::AST::Node? + + attr_reader old_index: Integer + + attr_reader new_index: Integer + + # : (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void + def initialize: (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void + + # : () -> Hash[Symbol, untyped] + def to_hash: () -> Hash[Symbol, untyped] + + # : () -> String + def inspect: () -> String + end +end diff --git a/sig/herb/diff_result.rbs b/sig/herb/diff_result.rbs new file mode 100644 index 000000000..4222f688e --- /dev/null +++ b/sig/herb/diff_result.rbs @@ -0,0 +1,25 @@ +# Generated from lib/herb/diff_result.rb with RBS::Inline + +module Herb + class DiffResult + attr_reader operations: Array[Herb::DiffOperation] + + # : (bool, Array[Herb::DiffOperation]) -> void + def initialize: (bool, Array[Herb::DiffOperation]) -> void + + # : () -> bool + def identical?: () -> bool + + # : () -> Integer + def operation_count: () -> Integer + + # : () -> bool + def changed?: () -> bool + + # : () -> Hash[Symbol, untyped] + def to_hash: () -> Hash[Symbol, untyped] + + # : () -> String + def inspect: () -> String + end +end diff --git a/src/diff/herb_diff.c b/src/diff/herb_diff.c new file mode 100644 index 000000000..f477530ac --- /dev/null +++ b/src/diff/herb_diff.c @@ -0,0 +1,126 @@ +#include "../include/diff/herb_diff.h" + +herb_diff_path_T herb_diff_path_empty(void) { + herb_diff_path_T path; + path.depth = 0; + return path; +} + +herb_diff_path_T herb_diff_path_append(const herb_diff_path_T path, const uint32_t index) { + herb_diff_path_T result = path; + + if (result.depth < HERB_DIFF_PATH_MAX_DEPTH) { + result.indices[result.depth] = index; + result.depth++; + } + + return result; +} + +static void emit_operation( + herb_diff_result_T* result, + const herb_diff_operation_type_T type, + const herb_diff_path_T path, + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + const uint32_t old_index, + const uint32_t new_index +) { + herb_diff_operation_T* operation = + (herb_diff_operation_T*) hb_allocator_alloc(result->allocator, sizeof(herb_diff_operation_T)); + + operation->type = type; + operation->path = path; + operation->old_node = old_node; + operation->new_node = new_node; + operation->old_index = old_index; + operation->new_index = new_index; + + hb_array_append(result->operations, operation); +} + +herb_diff_result_T* herb_diff( + const AST_DOCUMENT_NODE_T* old_document, + const AST_DOCUMENT_NODE_T* new_document, + hb_allocator_T* allocator +) { + herb_diff_result_T* result = (herb_diff_result_T*) hb_allocator_alloc(allocator, sizeof(herb_diff_result_T)); + result->operations = hb_array_init(16, allocator); + result->allocator = allocator; + result->trees_identical = false; + + herb_hash_map_T old_hashes; + herb_hash_map_T new_hashes; + herb_hash_map_init(&old_hashes, 256, allocator); + herb_hash_map_init(&new_hashes, 256, allocator); + + herb_hash_T old_root_hash = herb_hash_tree((const AST_NODE_T*) old_document, &old_hashes); + herb_hash_T new_root_hash = herb_hash_tree((const AST_NODE_T*) new_document, &new_hashes); + + if (old_root_hash == new_root_hash) { + result->trees_identical = true; + return result; + } + + herb_diff_path_T root_path = herb_diff_path_empty(); + + herb_diff_node( + (const AST_NODE_T*) old_document, + (const AST_NODE_T*) new_document, + root_path, + &old_hashes, + &new_hashes, + result + ); + + return result; +} + +size_t herb_diff_operation_count(const herb_diff_result_T* result) { + if (result == NULL || result->operations == NULL) { return 0; } + + return hb_array_size(result->operations); +} + +const herb_diff_operation_T* herb_diff_operation_at(const herb_diff_result_T* result, const size_t index) { + if (result == NULL || result->operations == NULL) { return NULL; } + + return (const herb_diff_operation_T*) hb_array_get(result->operations, index); +} + +bool herb_diff_trees_identical(const herb_diff_result_T* result) { + if (result == NULL) { return false; } + + return result->trees_identical; +} + +const char* herb_diff_operation_type_to_string(const herb_diff_operation_type_T type) { + switch (type) { + case HERB_DIFF_NODE_INSERTED: return "node_inserted"; + case HERB_DIFF_NODE_REMOVED: return "node_removed"; + case HERB_DIFF_NODE_REPLACED: return "node_replaced"; + case HERB_DIFF_NODE_MOVED: return "node_moved"; + case HERB_DIFF_TEXT_CHANGED: return "text_changed"; + case HERB_DIFF_ERB_CONTENT_CHANGED: return "erb_content_changed"; + case HERB_DIFF_ATTRIBUTE_ADDED: return "attribute_added"; + case HERB_DIFF_ATTRIBUTE_REMOVED: return "attribute_removed"; + case HERB_DIFF_ATTRIBUTE_VALUE_CHANGED: return "attribute_value_changed"; + case HERB_DIFF_TAG_NAME_CHANGED: return "tag_name_changed"; + case HERB_DIFF_NODE_WRAPPED: return "node_wrapped"; + case HERB_DIFF_NODE_UNWRAPPED: return "node_unwrapped"; + } + + return "unknown"; +} + +void herb_diff_emit_operation( + herb_diff_result_T* result, + const herb_diff_operation_type_T type, + const herb_diff_path_T path, + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + const uint32_t old_index, + const uint32_t new_index +) { + emit_operation(result, type, path, old_node, new_node, old_index, new_index); +} diff --git a/src/diff/herb_diff_attributes.c b/src/diff/herb_diff_attributes.c new file mode 100644 index 000000000..482500c86 --- /dev/null +++ b/src/diff/herb_diff_attributes.c @@ -0,0 +1,207 @@ +#include "../include/diff/herb_diff.h" + +void herb_diff_attributes( + const hb_array_T* old_attributes, + const hb_array_T* new_attributes, + const herb_diff_path_T parent_path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (old_attributes == NULL && new_attributes == NULL) { return; } + + if (old_attributes == NULL) { + for (size_t index = 0; index < hb_array_size(new_attributes); index++) { + const AST_NODE_T* new_attribute = (const AST_NODE_T*) hb_array_get(new_attributes, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_ADDED, + herb_diff_path_append(parent_path, (uint32_t) index), + NULL, + new_attribute, + 0, + (uint32_t) index + ); + } + + return; + } + + if (new_attributes == NULL) { + for (size_t index = 0; index < hb_array_size(old_attributes); index++) { + const AST_NODE_T* old_attribute = (const AST_NODE_T*) hb_array_get(old_attributes, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) index), + old_attribute, + NULL, + (uint32_t) index, + 0 + ); + } + + return; + } + + const size_t old_size = hb_array_size(old_attributes); + const size_t new_size = hb_array_size(new_attributes); + + if (old_size == 0) { + for (size_t index = 0; index < new_size; index++) { + const AST_NODE_T* new_attribute = (const AST_NODE_T*) hb_array_get(new_attributes, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_ADDED, + herb_diff_path_append(parent_path, (uint32_t) index), + NULL, + new_attribute, + 0, + (uint32_t) index + ); + } + + return; + } + + if (new_size == 0) { + for (size_t index = 0; index < old_size; index++) { + const AST_NODE_T* old_attribute = (const AST_NODE_T*) hb_array_get(old_attributes, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) index), + old_attribute, + NULL, + (uint32_t) index, + 0 + ); + } + + return; + } + + bool* old_matched = (bool*) hb_allocator_alloc(result->allocator, old_size * sizeof(bool)); + + for (size_t index = 0; index < old_size; index++) { + old_matched[index] = false; + } + + for (size_t new_index = 0; new_index < new_size; new_index++) { + const AST_NODE_T* new_node = (const AST_NODE_T*) hb_array_get(new_attributes, new_index); + + if (new_node->type != AST_HTML_ATTRIBUTE_NODE) { + bool found = false; + + for (size_t old_index = 0; old_index < old_size; old_index++) { + if (old_matched[old_index]) { continue; } + + const AST_NODE_T* old_node = (const AST_NODE_T*) hb_array_get(old_attributes, old_index); + + if (old_node->type == new_node->type) { + old_matched[old_index] = true; + found = true; + + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_node); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_node); + + if (old_hash != new_hash) { + herb_diff_node( + old_node, + new_node, + herb_diff_path_append(parent_path, (uint32_t) new_index), + old_hashes, + new_hashes, + result + ); + } + + break; + } + } + + if (!found) { + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_ADDED, + herb_diff_path_append(parent_path, (uint32_t) new_index), + NULL, + new_node, + 0, + (uint32_t) new_index + ); + } + + continue; + } + + const AST_HTML_ATTRIBUTE_NODE_T* new_attribute = (const AST_HTML_ATTRIBUTE_NODE_T*) new_node; + herb_hash_T new_name_hash = herb_hash_map_get(new_hashes, (const AST_NODE_T*) new_attribute->name); + + bool found = false; + + for (size_t old_index = 0; old_index < old_size; old_index++) { + if (old_matched[old_index]) { continue; } + + const AST_NODE_T* old_node = (const AST_NODE_T*) hb_array_get(old_attributes, old_index); + if (old_node->type != AST_HTML_ATTRIBUTE_NODE) { continue; } + + const AST_HTML_ATTRIBUTE_NODE_T* old_attribute = (const AST_HTML_ATTRIBUTE_NODE_T*) old_node; + herb_hash_T old_name_hash = herb_hash_map_get(old_hashes, (const AST_NODE_T*) old_attribute->name); + + if (old_name_hash == new_name_hash) { + old_matched[old_index] = true; + found = true; + + herb_hash_T old_value_hash = herb_hash_map_get(old_hashes, (const AST_NODE_T*) old_attribute->value); + herb_hash_T new_value_hash = herb_hash_map_get(new_hashes, (const AST_NODE_T*) new_attribute->value); + + if (old_value_hash != new_value_hash) { + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_VALUE_CHANGED, + herb_diff_path_append(parent_path, (uint32_t) new_index), + old_node, + new_node, + (uint32_t) old_index, + (uint32_t) new_index + ); + } + + break; + } + } + + if (!found) { + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_ADDED, + herb_diff_path_append(parent_path, (uint32_t) new_index), + NULL, + new_node, + 0, + (uint32_t) new_index + ); + } + } + + for (size_t old_index = 0; old_index < old_size; old_index++) { + if (!old_matched[old_index]) { + const AST_NODE_T* old_node = (const AST_NODE_T*) hb_array_get(old_attributes, old_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_ATTRIBUTE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) old_index), + old_node, + NULL, + (uint32_t) old_index, + 0 + ); + } + } +} diff --git a/src/diff/herb_diff_children.c b/src/diff/herb_diff_children.c new file mode 100644 index 000000000..f070ec7c7 --- /dev/null +++ b/src/diff/herb_diff_children.c @@ -0,0 +1,500 @@ +#include "../include/diff/herb_diff.h" +#include "../include/macros.h" + +#include + +#define LCS_MAX_SIZE 256 + +typedef enum { + EDIT_KEEP, + EDIT_INSERT, + EDIT_DELETE, + EDIT_MOVE, + EDIT_CONSUMED, + EDIT_COALESCED_KEEP, + EDIT_WRAP, + EDIT_UNWRAP, +} edit_type_T; + +typedef struct { + edit_type_T type; + size_t old_index; + size_t new_index; +} edit_entry_T; + +static size_t element_attribute_count(const AST_NODE_T* node) { + if (node->type == AST_HTML_ELEMENT_NODE) { + const AST_HTML_ELEMENT_NODE_T* element = (const AST_HTML_ELEMENT_NODE_T*) node; + + if (element->open_tag != NULL) { + const AST_HTML_OPEN_TAG_NODE_T* open_tag = (const AST_HTML_OPEN_TAG_NODE_T*) element->open_tag; + + if (open_tag->children != NULL) { return hb_array_size(open_tag->children); } + } + } + + if (node->type == AST_HTML_CONDITIONAL_ELEMENT_NODE) { + const AST_HTML_CONDITIONAL_ELEMENT_NODE_T* element = (const AST_HTML_CONDITIONAL_ELEMENT_NODE_T*) node; + + if (element->open_tag != NULL && element->open_tag->children != NULL) { + return hb_array_size(element->open_tag->children); + } + } + + return 0; +} + +static bool nodes_match( + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes +) { + if (old_node == NULL || new_node == NULL) { return false; } + + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_node); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_node); + + if (old_hash == new_hash) { return true; } + if (old_node->type != new_node->type) { return false; } + + if (old_node->type == AST_HTML_ELEMENT_NODE || old_node->type == AST_HTML_CONDITIONAL_ELEMENT_NODE) { + herb_hash_T old_identity = herb_hash_node_identity(old_node, old_hashes); + herb_hash_T new_identity = herb_hash_node_identity(new_node, new_hashes); + + if (old_identity != new_identity) { return false; } + + if (element_attribute_count(old_node) == 0 || element_attribute_count(new_node) == 0) { return true; } + + herb_hash_T old_move_identity = herb_hash_node_move_identity(old_node, old_hashes); + herb_hash_T new_move_identity = herb_hash_node_move_identity(new_node, new_hashes); + + return old_move_identity == new_move_identity; + } + + return true; +} + +static void diff_children_linear( + const hb_array_T* old_children, + const hb_array_T* new_children, + const herb_diff_path_T parent_path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + const size_t old_size = hb_array_size(old_children); + const size_t new_size = hb_array_size(new_children); + + size_t common_prefix = 0; + + while (common_prefix < old_size && common_prefix < new_size) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, common_prefix); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, common_prefix); + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_child); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_child); + + if (old_hash != new_hash) { break; } + + common_prefix++; + } + + size_t common_suffix = 0; + + while (common_suffix < (old_size - common_prefix) && common_suffix < (new_size - common_prefix)) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, old_size - 1 - common_suffix); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, new_size - 1 - common_suffix); + + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_child); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_child); + + if (old_hash != new_hash) { break; } + + common_suffix++; + } + + const size_t old_middle_start = common_prefix; + const size_t old_middle_end = old_size - common_suffix; + const size_t new_middle_start = common_prefix; + const size_t new_middle_end = new_size - common_suffix; + + for (size_t index = old_middle_start; index < old_middle_end; index++) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) index), + old_child, + NULL, + (uint32_t) index, + 0 + ); + } + + for (size_t index = new_middle_start; index < new_middle_end; index++) { + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_INSERTED, + herb_diff_path_append(parent_path, (uint32_t) index), + NULL, + new_child, + 0, + (uint32_t) index + ); + } +} + +void herb_diff_children( + const hb_array_T* old_children, + const hb_array_T* new_children, + const herb_diff_path_T parent_path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (old_children == NULL && new_children == NULL) { return; } + + if (old_children == NULL) { + for (size_t index = 0; index < hb_array_size(new_children); index++) { + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_INSERTED, + herb_diff_path_append(parent_path, (uint32_t) index), + NULL, + new_child, + 0, + (uint32_t) index + ); + } + + return; + } + + if (new_children == NULL) { + for (size_t index = 0; index < hb_array_size(old_children); index++) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) index), + old_child, + NULL, + (uint32_t) index, + 0 + ); + } + + return; + } + + const size_t old_size = hb_array_size(old_children); + const size_t new_size = hb_array_size(new_children); + + if (old_size == 0 && new_size == 0) { return; } + + if (old_size > LCS_MAX_SIZE || new_size > LCS_MAX_SIZE) { + diff_children_linear(old_children, new_children, parent_path, old_hashes, new_hashes, result); + return; + } + + const size_t table_width = new_size + 1; + const size_t table_size = (old_size + 1) * table_width; + size_t* lcs_table = (size_t*) hb_allocator_alloc(result->allocator, table_size * sizeof(size_t)); + memset(lcs_table, 0, table_size * sizeof(size_t)); + + for (size_t old_index = 1; old_index <= old_size; old_index++) { + for (size_t new_index = 1; new_index <= new_size; new_index++) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, old_index - 1); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, new_index - 1); + + if (nodes_match(old_child, new_child, old_hashes, new_hashes)) { + lcs_table[old_index * table_width + new_index] = lcs_table[(old_index - 1) * table_width + (new_index - 1)] + 1; + } else { + const size_t from_old = lcs_table[(old_index - 1) * table_width + new_index]; + const size_t from_new = lcs_table[old_index * table_width + (new_index - 1)]; + lcs_table[old_index * table_width + new_index] = MAX(from_old, from_new); + } + } + } + + size_t edit_capacity = old_size + new_size; + size_t edit_count = 0; + + edit_entry_T* edit_script = + (edit_entry_T*) hb_allocator_alloc(result->allocator, edit_capacity * sizeof(edit_entry_T)); + + size_t old_index = old_size; + size_t new_index = new_size; + + while (old_index > 0 || new_index > 0) { + if (old_index > 0 && new_index > 0) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, old_index - 1); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, new_index - 1); + + if (nodes_match(old_child, new_child, old_hashes, new_hashes)) { + edit_script[edit_count].type = EDIT_KEEP; + edit_script[edit_count].old_index = old_index - 1; + edit_script[edit_count].new_index = new_index - 1; + edit_count++; + + old_index--; + new_index--; + + continue; + } + } + + if (new_index > 0 + && (old_index == 0 || lcs_table[old_index * table_width + (new_index - 1)] >= lcs_table[(old_index - 1) * table_width + new_index])) { + edit_script[edit_count].type = EDIT_INSERT; + edit_script[edit_count].old_index = 0; + edit_script[edit_count].new_index = new_index - 1; + edit_count++; + new_index--; + } else { + edit_script[edit_count].type = EDIT_DELETE; + edit_script[edit_count].old_index = old_index - 1; + edit_script[edit_count].new_index = 0; + edit_count++; + old_index--; + } + } + + size_t remove_count = 0; + size_t insert_count = 0; + + for (size_t index = 0; index < edit_count; index++) { + if (edit_script[index].type == EDIT_DELETE) { remove_count++; } + if (edit_script[index].type == EDIT_INSERT) { insert_count++; } + } + + bool* remove_matched = NULL; + bool* insert_matched = NULL; + + if (remove_count > 0 && insert_count > 0) { + size_t remove_alloc = remove_count * sizeof(size_t); + size_t insert_alloc = insert_count * sizeof(size_t); + + size_t* remove_indices = (size_t*) hb_allocator_alloc(result->allocator, remove_alloc); + size_t* insert_indices = (size_t*) hb_allocator_alloc(result->allocator, insert_alloc); + + remove_matched = (bool*) hb_allocator_alloc(result->allocator, remove_count * sizeof(bool)); + insert_matched = (bool*) hb_allocator_alloc(result->allocator, insert_count * sizeof(bool)); + + size_t remove_position = 0; + size_t insert_position = 0; + + for (size_t index = 0; index < edit_count; index++) { + if (edit_script[index].type == EDIT_DELETE) { + remove_indices[remove_position] = index; + remove_matched[remove_position] = false; + remove_position++; + } + + if (edit_script[index].type == EDIT_INSERT) { + insert_indices[insert_position] = index; + insert_matched[insert_position] = false; + insert_position++; + } + } + + for (size_t remove_index = 0; remove_index < remove_count; remove_index++) { + if (remove_matched[remove_index]) { continue; } + + const edit_entry_T* remove_entry = &edit_script[remove_indices[remove_index]]; + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); + herb_hash_T old_identity = herb_hash_node_move_identity(old_child, old_hashes); + + for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { + if (insert_matched[insert_index]) { continue; } + + const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); + herb_hash_T new_identity = herb_hash_node_move_identity(new_child, new_hashes); + + if (old_identity == new_identity) { + remove_matched[remove_index] = true; + insert_matched[insert_index] = true; + + edit_script[remove_indices[remove_index]].type = EDIT_MOVE; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + + break; + } + } + } + + for (size_t remove_index = 0; remove_index < remove_count; remove_index++) { + if (remove_matched[remove_index]) { continue; } + + const edit_entry_T* remove_entry = &edit_script[remove_indices[remove_index]]; + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); + herb_hash_T old_tag_identity = herb_hash_node_identity(old_child, old_hashes); + + for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { + if (insert_matched[insert_index]) { continue; } + + const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); + + herb_hash_T new_tag_identity = herb_hash_node_identity(new_child, new_hashes); + + if (old_tag_identity == new_tag_identity) { + remove_matched[remove_index] = true; + insert_matched[insert_index] = true; + + edit_script[remove_indices[remove_index]].type = EDIT_COALESCED_KEEP; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + + break; + } + } + } + + for (size_t remove_index = 0; remove_index < remove_count; remove_index++) { + if (remove_matched[remove_index]) { continue; } + + const edit_entry_T* remove_entry = &edit_script[remove_indices[remove_index]]; + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_child); + + for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { + if (insert_matched[insert_index]) { continue; } + + const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_child); + + const AST_NODE_T* found_in_new = herb_diff_find_child_by_hash(new_child, old_hash, new_hashes); + + if (found_in_new != NULL) { + remove_matched[remove_index] = true; + insert_matched[insert_index] = true; + + edit_script[remove_indices[remove_index]].type = EDIT_WRAP; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + + break; + } + + const AST_NODE_T* found_in_old = herb_diff_find_child_by_hash(old_child, new_hash, old_hashes); + + if (found_in_old != NULL) { + remove_matched[remove_index] = true; + insert_matched[insert_index] = true; + + edit_script[remove_indices[remove_index]].type = EDIT_UNWRAP; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + + break; + } + } + } + } + + for (size_t index = edit_count; index > 0; index--) { + const edit_entry_T* entry = &edit_script[index - 1]; + + if (entry->type == EDIT_KEEP) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + + herb_diff_path_T child_path = herb_diff_path_append(parent_path, (uint32_t) entry->new_index); + herb_diff_node(old_child, new_child, child_path, old_hashes, new_hashes, result); + } else if (entry->type == EDIT_INSERT) { + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_INSERTED, + herb_diff_path_append(parent_path, (uint32_t) entry->new_index), + NULL, + new_child, + 0, + (uint32_t) entry->new_index + ); + } else if (entry->type == EDIT_DELETE) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_REMOVED, + herb_diff_path_append(parent_path, (uint32_t) entry->old_index), + old_child, + NULL, + (uint32_t) entry->old_index, + 0 + ); + } else if (entry->type == EDIT_MOVE) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_MOVED, + herb_diff_path_append(parent_path, (uint32_t) entry->new_index), + old_child, + new_child, + (uint32_t) entry->old_index, + (uint32_t) entry->new_index + ); + + herb_diff_path_T child_path = herb_diff_path_append(parent_path, (uint32_t) entry->new_index); + herb_diff_node(old_child, new_child, child_path, old_hashes, new_hashes, result); + } else if (entry->type == EDIT_COALESCED_KEEP) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + herb_diff_path_T child_path = herb_diff_path_append(parent_path, (uint32_t) entry->new_index); + + if (entry->old_index != entry->new_index) { + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_MOVED, + child_path, + old_child, + new_child, + (uint32_t) entry->old_index, + (uint32_t) entry->new_index + ); + } + + herb_diff_node(old_child, new_child, child_path, old_hashes, new_hashes, result); + } else if (entry->type == EDIT_WRAP) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_WRAPPED, + herb_diff_path_append(parent_path, (uint32_t) entry->new_index), + old_child, + new_child, + (uint32_t) entry->old_index, + (uint32_t) entry->new_index + ); + } else if (entry->type == EDIT_UNWRAP) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, entry->old_index); + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, entry->new_index); + + herb_diff_emit_operation( + result, + HERB_DIFF_NODE_UNWRAPPED, + herb_diff_path_append(parent_path, (uint32_t) entry->new_index), + old_child, + new_child, + (uint32_t) entry->old_index, + (uint32_t) entry->new_index + ); + } + } +} diff --git a/src/diff/herb_hash.c b/src/diff/herb_hash.c new file mode 100644 index 000000000..e2a80b0aa --- /dev/null +++ b/src/diff/herb_hash.c @@ -0,0 +1,42 @@ +#include "../include/diff/herb_hash.h" + +herb_hash_T herb_hash_byte(herb_hash_T hash, const uint8_t byte) { + hash ^= (herb_hash_T) byte; + hash *= HERB_HASH_FNV_PRIME; + + return hash; +} + +herb_hash_T herb_hash_bytes(herb_hash_T hash, const void* data, const size_t length) { + const uint8_t* bytes = (const uint8_t*) data; + + for (size_t index = 0; index < length; index++) { + hash = herb_hash_byte(hash, bytes[index]); + } + + return hash; +} + +herb_hash_T herb_hash_uint32(herb_hash_T hash, const uint32_t value) { + return herb_hash_bytes(hash, &value, sizeof(value)); +} + +herb_hash_T herb_hash_uint64(herb_hash_T hash, const uint64_t value) { + return herb_hash_bytes(hash, &value, sizeof(value)); +} + +herb_hash_T herb_hash_bool(herb_hash_T hash, const bool value) { + const uint8_t byte = value ? 1 : 0; + + return herb_hash_byte(hash, byte); +} + +herb_hash_T herb_hash_string(herb_hash_T hash, const hb_string_T string) { + hash = herb_hash_uint64(hash, (uint64_t) string.length); + + return herb_hash_bytes(hash, string.data, string.length); +} + +herb_hash_T herb_hash_combine(herb_hash_T hash, const herb_hash_T other) { + return herb_hash_uint64(hash, other); +} diff --git a/src/diff/herb_hash_map.c b/src/diff/herb_hash_map.c new file mode 100644 index 000000000..e618dc192 --- /dev/null +++ b/src/diff/herb_hash_map.c @@ -0,0 +1,104 @@ +#include "../include/diff/herb_hash_map.h" + +#include + +static size_t hash_pointer(const AST_NODE_T* node, const size_t capacity) { + uintptr_t pointer_value = (uintptr_t) node; + + pointer_value = ((pointer_value >> 16) ^ pointer_value) * 0x45d9f3b; + pointer_value = ((pointer_value >> 16) ^ pointer_value) * 0x45d9f3b; + pointer_value = (pointer_value >> 16) ^ pointer_value; + + return (size_t) (pointer_value % capacity); +} + +static bool herb_hash_map_grow(herb_hash_map_T* map) { + const size_t new_capacity = map->capacity * 2; + const size_t alloc_size = new_capacity * sizeof(herb_hash_map_entry_T); + + herb_hash_map_entry_T* new_entries = (herb_hash_map_entry_T*) hb_allocator_alloc(map->allocator, alloc_size); + + if (new_entries == NULL) { return false; } + + memset(new_entries, 0, alloc_size); + + for (size_t index = 0; index < map->capacity; index++) { + if (!map->entries[index].occupied) { continue; } + + size_t slot = hash_pointer(map->entries[index].node, new_capacity); + + while (new_entries[slot].occupied) { + slot = (slot + 1) % new_capacity; + } + + new_entries[slot] = map->entries[index]; + } + + map->entries = new_entries; + map->capacity = new_capacity; + + return true; +} + +bool herb_hash_map_init(herb_hash_map_T* map, const size_t initial_capacity, hb_allocator_T* allocator) { + const size_t alloc_size = initial_capacity * sizeof(herb_hash_map_entry_T); + + map->entries = (herb_hash_map_entry_T*) hb_allocator_alloc(allocator, alloc_size); + + if (map->entries == NULL) { return false; } + + memset(map->entries, 0, alloc_size); + map->capacity = initial_capacity; + map->size = 0; + map->allocator = allocator; + + return true; +} + +void herb_hash_map_set(herb_hash_map_T* map, const AST_NODE_T* node, const herb_hash_T hash) { + if (map->size * 4 >= map->capacity * 3) { herb_hash_map_grow(map); } + + size_t slot = hash_pointer(node, map->capacity); + + while (map->entries[slot].occupied) { + if (map->entries[slot].node == node) { + map->entries[slot].hash = hash; + return; + } + + slot = (slot + 1) % map->capacity; + } + + map->entries[slot].node = node; + map->entries[slot].hash = hash; + map->entries[slot].occupied = true; + map->size++; +} + +herb_hash_T herb_hash_map_get(const herb_hash_map_T* map, const AST_NODE_T* node) { + if (node == NULL || map->size == 0) { return HERB_HASH_INIT; } + + size_t slot = hash_pointer(node, map->capacity); + + while (map->entries[slot].occupied) { + if (map->entries[slot].node == node) { return map->entries[slot].hash; } + + slot = (slot + 1) % map->capacity; + } + + return HERB_HASH_INIT; +} + +bool herb_hash_map_has(const herb_hash_map_T* map, const AST_NODE_T* node) { + if (node == NULL || map->size == 0) { return false; } + + size_t slot = hash_pointer(node, map->capacity); + + while (map->entries[slot].occupied) { + if (map->entries[slot].node == node) { return true; } + + slot = (slot + 1) % map->capacity; + } + + return false; +} diff --git a/src/include/diff/herb_diff.h b/src/include/diff/herb_diff.h new file mode 100644 index 000000000..cdafc6195 --- /dev/null +++ b/src/include/diff/herb_diff.h @@ -0,0 +1,118 @@ +#ifndef HERB_DIFF_H +#define HERB_DIFF_H + +#include +#include +#include + +#include "../ast/ast_nodes.h" +#include "../lib/hb_allocator.h" +#include "../lib/hb_array.h" +#include "herb_hash.h" +#include "herb_hash_map.h" + +#define HERB_DIFF_PATH_MAX_DEPTH 64 + +typedef enum { + HERB_DIFF_NODE_INSERTED, + HERB_DIFF_NODE_REMOVED, + HERB_DIFF_NODE_REPLACED, + HERB_DIFF_NODE_MOVED, + + HERB_DIFF_TEXT_CHANGED, + HERB_DIFF_ERB_CONTENT_CHANGED, + + HERB_DIFF_ATTRIBUTE_ADDED, + HERB_DIFF_ATTRIBUTE_REMOVED, + HERB_DIFF_ATTRIBUTE_VALUE_CHANGED, + + HERB_DIFF_TAG_NAME_CHANGED, + + HERB_DIFF_NODE_WRAPPED, + HERB_DIFF_NODE_UNWRAPPED, +} herb_diff_operation_type_T; + +typedef struct { + uint32_t indices[HERB_DIFF_PATH_MAX_DEPTH]; + uint16_t depth; +} herb_diff_path_T; + +typedef struct { + herb_diff_operation_type_T type; + herb_diff_path_T path; + const AST_NODE_T* old_node; + const AST_NODE_T* new_node; + uint32_t old_index; + uint32_t new_index; +} herb_diff_operation_T; + +typedef struct { + hb_array_T* operations; + hb_allocator_T* allocator; + bool trees_identical; +} herb_diff_result_T; + +herb_diff_path_T herb_diff_path_empty(void); +herb_diff_path_T herb_diff_path_append(herb_diff_path_T path, uint32_t index); + +herb_diff_result_T* herb_diff( + const AST_DOCUMENT_NODE_T* old_document, + const AST_DOCUMENT_NODE_T* new_document, + hb_allocator_T* allocator +); + +size_t herb_diff_operation_count(const herb_diff_result_T* result); +const herb_diff_operation_T* herb_diff_operation_at(const herb_diff_result_T* result, size_t index); +bool herb_diff_trees_identical(const herb_diff_result_T* result); +const char* herb_diff_operation_type_to_string(herb_diff_operation_type_T type); + +void herb_diff_node( + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + herb_diff_path_T path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +); + +void herb_diff_children( + const hb_array_T* old_children, + const hb_array_T* new_children, + herb_diff_path_T parent_path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +); + +void herb_diff_attributes( + const hb_array_T* old_attributes, + const hb_array_T* new_attributes, + herb_diff_path_T parent_path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +); + +void herb_diff_emit_operation( + herb_diff_result_T* result, + herb_diff_operation_type_T type, + herb_diff_path_T path, + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + uint32_t old_index, + uint32_t new_index +); + +const hb_array_T* herb_diff_get_node_children(const AST_NODE_T* node); + +const AST_NODE_T* herb_diff_find_child_by_hash( + const AST_NODE_T* parent, + herb_hash_T target_hash, + const herb_hash_map_T* hash_map +); + +herb_hash_T herb_hash_tree(const AST_NODE_T* node, herb_hash_map_T* hash_map); +herb_hash_T herb_hash_node_identity(const AST_NODE_T* node, const herb_hash_map_T* hash_map); +herb_hash_T herb_hash_node_move_identity(const AST_NODE_T* node, const herb_hash_map_T* hash_map); + +#endif diff --git a/src/include/diff/herb_hash.h b/src/include/diff/herb_hash.h new file mode 100644 index 000000000..bb9169001 --- /dev/null +++ b/src/include/diff/herb_hash.h @@ -0,0 +1,22 @@ +#ifndef HERB_HASH_H +#define HERB_HASH_H + +#include +#include + +#include "../lib/hb_string.h" + +typedef uint64_t herb_hash_T; + +#define HERB_HASH_INIT ((herb_hash_T) 0xcbf29ce484222325ULL) +#define HERB_HASH_FNV_PRIME ((herb_hash_T) 0x100000001b3ULL) + +herb_hash_T herb_hash_byte(herb_hash_T hash, uint8_t byte); +herb_hash_T herb_hash_bytes(herb_hash_T hash, const void* data, size_t length); +herb_hash_T herb_hash_uint32(herb_hash_T hash, uint32_t value); +herb_hash_T herb_hash_uint64(herb_hash_T hash, uint64_t value); +herb_hash_T herb_hash_bool(herb_hash_T hash, bool value); +herb_hash_T herb_hash_string(herb_hash_T hash, hb_string_T string); +herb_hash_T herb_hash_combine(herb_hash_T hash, herb_hash_T other); + +#endif diff --git a/src/include/diff/herb_hash_map.h b/src/include/diff/herb_hash_map.h new file mode 100644 index 000000000..77b6df671 --- /dev/null +++ b/src/include/diff/herb_hash_map.h @@ -0,0 +1,30 @@ +#ifndef HERB_HASH_MAP_H +#define HERB_HASH_MAP_H + +#include +#include +#include + +#include "../ast/ast_nodes.h" +#include "../lib/hb_allocator.h" +#include "herb_hash.h" + +typedef struct { + const AST_NODE_T* node; + herb_hash_T hash; + bool occupied; +} herb_hash_map_entry_T; + +typedef struct { + herb_hash_map_entry_T* entries; + size_t capacity; + size_t size; + hb_allocator_T* allocator; +} herb_hash_map_T; + +bool herb_hash_map_init(herb_hash_map_T* map, size_t initial_capacity, hb_allocator_T* allocator); +void herb_hash_map_set(herb_hash_map_T* map, const AST_NODE_T* node, herb_hash_T hash); +herb_hash_T herb_hash_map_get(const herb_hash_map_T* map, const AST_NODE_T* node); +bool herb_hash_map_has(const herb_hash_map_T* map, const AST_NODE_T* node); + +#endif diff --git a/src/include/herb.h b/src/include/herb.h index cf0bcc98b..1b5f3d472 100644 --- a/src/include/herb.h +++ b/src/include/herb.h @@ -2,6 +2,7 @@ #define HERB_H #include "ast/ast_node.h" +#include "diff/herb_diff.h" #include "extract.h" #include "lib/hb_allocator.h" #include "lib/hb_array.h" diff --git a/templates/src/diff/herb_diff_helpers.c.erb b/templates/src/diff/herb_diff_helpers.c.erb new file mode 100644 index 000000000..cad98efe1 --- /dev/null +++ b/templates/src/diff/herb_diff_helpers.c.erb @@ -0,0 +1,38 @@ +#include "../include/diff/herb_diff.h" + +const hb_array_T* herb_diff_get_node_children(const AST_NODE_T* node) { + if (node == NULL) { return NULL; } + + switch (node->type) { + <%- nodes.each do |node| -%> + <%- primary_array = node.fields.find { |field| field.is_a?(Herb::Template::ArrayField) && (field.name == "children" || field.name == "body" || field.name == "statements" || field.name == "conditions") } -%> + <%- if primary_array -%> + case <%= node.type %>: + return ((const <%= node.struct_type %>*) node)-><%= primary_array.name %>; + + <%- end -%> + <%- end -%> + default: + return NULL; + } +} + +const AST_NODE_T* herb_diff_find_child_by_hash( + const AST_NODE_T* parent, + const herb_hash_T target_hash, + const herb_hash_map_T* hash_map +) { + const hb_array_T* children = herb_diff_get_node_children(parent); + if (children == NULL) { return NULL; } + + const size_t count = hb_array_size(children); + + for (size_t index = 0; index < count; index++) { + const AST_NODE_T* child = (const AST_NODE_T*) hb_array_get(children, index); + herb_hash_T child_hash = herb_hash_map_get(hash_map, child); + + if (child_hash == target_hash) { return child; } + } + + return NULL; +} diff --git a/templates/src/diff/herb_diff_nodes.c.erb b/templates/src/diff/herb_diff_nodes.c.erb new file mode 100644 index 000000000..24a9f9b90 --- /dev/null +++ b/templates/src/diff/herb_diff_nodes.c.erb @@ -0,0 +1,224 @@ +#include "../include/diff/herb_diff.h" +#include "../include/lib/hb_string.h" + +static bool tokens_equal(const token_T* old_token, const token_T* new_token) { + if (old_token == NULL && new_token == NULL) { return true; } + if (old_token == NULL || new_token == NULL) { return false; } + + return hb_string_equals(old_token->value, new_token->value); +} + +static void diff_element_node( + const AST_HTML_ELEMENT_NODE_T* old_element, + const AST_HTML_ELEMENT_NODE_T* new_element, + const herb_diff_path_T path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (!tokens_equal(old_element->tag_name, new_element->tag_name)) { + herb_diff_emit_operation(result, HERB_DIFF_TAG_NAME_CHANGED, path, (const AST_NODE_T*) old_element, (const AST_NODE_T*) new_element, 0, 0); + } + + if (old_element->open_tag != NULL && new_element->open_tag != NULL) { + herb_hash_T old_open_hash = herb_hash_map_get(old_hashes, old_element->open_tag); + herb_hash_T new_open_hash = herb_hash_map_get(new_hashes, new_element->open_tag); + + if (old_open_hash != new_open_hash) { + const AST_HTML_OPEN_TAG_NODE_T* old_open_tag = (const AST_HTML_OPEN_TAG_NODE_T*) old_element->open_tag; + const AST_HTML_OPEN_TAG_NODE_T* new_open_tag = (const AST_HTML_OPEN_TAG_NODE_T*) new_element->open_tag; + + herb_diff_attributes(old_open_tag->children, new_open_tag->children, path, old_hashes, new_hashes, result); + } + } + + if (old_element->body != NULL && new_element->body != NULL) { + herb_diff_children(old_element->body, new_element->body, path, old_hashes, new_hashes, result); + } else if (old_element->body == NULL && new_element->body != NULL) { + for (size_t index = 0; index < hb_array_size(new_element->body); index++) { + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_element->body, index); + herb_diff_emit_operation(result, HERB_DIFF_NODE_INSERTED, herb_diff_path_append(path, (uint32_t) index), NULL, new_child, 0, (uint32_t) index); + } + } else if (old_element->body != NULL && new_element->body == NULL) { + for (size_t index = 0; index < hb_array_size(old_element->body); index++) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_element->body, index); + herb_diff_emit_operation(result, HERB_DIFF_NODE_REMOVED, herb_diff_path_append(path, (uint32_t) index), old_child, NULL, (uint32_t) index, 0); + } + } +} + +static void diff_conditional_element_node( + const AST_HTML_CONDITIONAL_ELEMENT_NODE_T* old_element, + const AST_HTML_CONDITIONAL_ELEMENT_NODE_T* new_element, + const herb_diff_path_T path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (!tokens_equal(old_element->tag_name, new_element->tag_name)) { + herb_diff_emit_operation(result, HERB_DIFF_TAG_NAME_CHANGED, path, (const AST_NODE_T*) old_element, (const AST_NODE_T*) new_element, 0, 0); + } + + if (old_element->open_tag != NULL && new_element->open_tag != NULL) { + herb_hash_T old_open_hash = herb_hash_map_get(old_hashes, (const AST_NODE_T*) old_element->open_tag); + herb_hash_T new_open_hash = herb_hash_map_get(new_hashes, (const AST_NODE_T*) new_element->open_tag); + + if (old_open_hash != new_open_hash) { + herb_diff_attributes(old_element->open_tag->children, new_element->open_tag->children, path, old_hashes, new_hashes, result); + } + } + + if (old_element->body != NULL && new_element->body != NULL) { + herb_diff_children(old_element->body, new_element->body, path, old_hashes, new_hashes, result); + } +} + +static void diff_children_nullable( + const hb_array_T* old_children, + const hb_array_T* new_children, + const herb_diff_path_T path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (old_children != NULL && new_children != NULL) { + herb_diff_children(old_children, new_children, path, old_hashes, new_hashes, result); + } else if (old_children == NULL && new_children != NULL) { + for (size_t index = 0; index < hb_array_size(new_children); index++) { + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, index); + herb_diff_emit_operation(result, HERB_DIFF_NODE_INSERTED, herb_diff_path_append(path, (uint32_t) index), NULL, new_child, 0, (uint32_t) index); + } + } else if (old_children != NULL && new_children == NULL) { + for (size_t index = 0; index < hb_array_size(old_children); index++) { + const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, index); + herb_diff_emit_operation(result, HERB_DIFF_NODE_REMOVED, herb_diff_path_append(path, (uint32_t) index), old_child, NULL, (uint32_t) index, 0); + } + } +} + +void herb_diff_node( + const AST_NODE_T* old_node, + const AST_NODE_T* new_node, + const herb_diff_path_T path, + const herb_hash_map_T* old_hashes, + const herb_hash_map_T* new_hashes, + herb_diff_result_T* result +) { + if (old_node == NULL && new_node == NULL) { return; } + + if (old_node == NULL) { + herb_diff_emit_operation(result, HERB_DIFF_NODE_INSERTED, path, NULL, new_node, 0, 0); + return; + } + + if (new_node == NULL) { + herb_diff_emit_operation(result, HERB_DIFF_NODE_REMOVED, path, old_node, NULL, 0, 0); + return; + } + + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_node); + herb_hash_T new_hash = herb_hash_map_get(new_hashes, new_node); + + if (old_hash == new_hash) { return; } + + if (old_node->type != new_node->type) { + herb_diff_emit_operation(result, HERB_DIFF_NODE_REPLACED, path, old_node, new_node, 0, 0); + return; + } + + switch (old_node->type) { + <%- nodes.each do |node| -%> + <%- # Skip nodes with custom handlers -%> + <%- if node.name == "HTMLElementNode" -%> + case <%= node.type %>: { + diff_element_node( + (const <%= node.struct_type %>*) old_node, + (const <%= node.struct_type %>*) new_node, + path, old_hashes, new_hashes, result + ); + } break; + + <%- elsif node.name == "HTMLConditionalElementNode" -%> + case <%= node.type %>: { + diff_conditional_element_node( + (const <%= node.struct_type %>*) old_node, + (const <%= node.struct_type %>*) new_node, + path, old_hashes, new_hashes, result + ); + } break; + + <%- elsif node.name == "HTMLAttributeNode" -%> + case <%= node.type %>: { + const <%= node.struct_type %>* old_attribute = (const <%= node.struct_type %>*) old_node; + const <%= node.struct_type %>* new_attribute = (const <%= node.struct_type %>*) new_node; + + herb_hash_T old_name_hash = herb_hash_map_get(old_hashes, (const AST_NODE_T*) old_attribute->name); + herb_hash_T new_name_hash = herb_hash_map_get(new_hashes, (const AST_NODE_T*) new_attribute->name); + + if (old_name_hash != new_name_hash) { + herb_diff_emit_operation(result, HERB_DIFF_NODE_REPLACED, path, old_node, new_node, 0, 0); + } else { + herb_hash_T old_value_hash = herb_hash_map_get(old_hashes, (const AST_NODE_T*) old_attribute->value); + herb_hash_T new_value_hash = herb_hash_map_get(new_hashes, (const AST_NODE_T*) new_attribute->value); + + if (old_value_hash != new_value_hash) { + herb_diff_emit_operation(result, HERB_DIFF_ATTRIBUTE_VALUE_CHANGED, path, old_node, new_node, 0, 0); + } + } + } break; + + <%- elsif node.name == "HTMLOpenTagNode" -%> + case <%= node.type %>: { + const <%= node.struct_type %>* old_open_tag = (const <%= node.struct_type %>*) old_node; + const <%= node.struct_type %>* new_open_tag = (const <%= node.struct_type %>*) new_node; + + herb_diff_attributes(old_open_tag->children, new_open_tag->children, path, old_hashes, new_hashes, result); + } break; + + <%- else -%> + <%- all_fields = node.fields.reject(&:always_invisible?).reject { |field| field.is_a?(Herb::Template::PrismNodeField) || field.is_a?(Herb::Template::LocationField) || field.is_a?(Herb::Template::BooleanField) || field.is_a?(Herb::Template::ElementSourceField) } -%> + <%- token_fields = all_fields.select { |field| field.is_a?(Herb::Template::TokenField) } -%> + <%- string_fields = all_fields.select { |field| field.is_a?(Herb::Template::StringField) } -%> + <%- array_fields = all_fields.select { |field| field.is_a?(Herb::Template::ArrayField) } -%> + <%- node_fields = all_fields.select { |field| field.is_a?(Herb::Template::NodeField) || field.is_a?(Herb::Template::BorrowedNodeField) } -%> + <%- content_tokens = token_fields.select { |field| %w[content tag_opening].include?(field.name) } -%> + <%- has_comparisons = content_tokens.any? || string_fields.any? || array_fields.any? || node_fields.any? -%> + <%- is_erb_node = node.name.start_with?("ERB") || node.name.start_with?("Ruby") -%> + <%- if !has_comparisons -%> + case <%= node.type %>: { + herb_diff_emit_operation(result, HERB_DIFF_NODE_REPLACED, path, old_node, new_node, 0, 0); + } break; + + <%- else -%> + case <%= node.type %>: { + const <%= node.struct_type %>* old_<%= node.human %> = (const <%= node.struct_type %>*) old_node; + const <%= node.struct_type %>* new_<%= node.human %> = (const <%= node.struct_type %>*) new_node; + + <%- if content_tokens.any? -%> + if (<%= content_tokens.map { |field| "!tokens_equal(old_#{node.human}->#{field.name}, new_#{node.human}->#{field.name})" }.join("\n || ") %>) { + herb_diff_emit_operation(result, <%= is_erb_node ? "HERB_DIFF_ERB_CONTENT_CHANGED" : "HERB_DIFF_TEXT_CHANGED" %>, path, old_node, new_node, 0, 0); + } + + <%- end -%> + + <%- if string_fields.any? -%> + if (<%= string_fields.map { |field| "!hb_string_equals(old_#{node.human}->#{field.name}, new_#{node.human}->#{field.name})" }.join("\n || ") %>) { + herb_diff_emit_operation(result, HERB_DIFF_TEXT_CHANGED, path, old_node, new_node, 0, 0); + } + + <%- end -%> + + <%- array_fields.each do |field| -%> + diff_children_nullable(old_<%= node.human %>-><%= field.name %>, new_<%= node.human %>-><%= field.name %>, path, old_hashes, new_hashes, result); + <%- end -%> + + <%- node_fields.each do |field| -%> + herb_diff_node((const AST_NODE_T*) old_<%= node.human %>-><%= field.name %>, (const AST_NODE_T*) new_<%= node.human %>-><%= field.name %>, path, old_hashes, new_hashes, result); + <%- end -%> + } break; + + <%- end -%> +<%- end -%> +<%- end -%> + } +} diff --git a/templates/src/diff/herb_hash_tree.c.erb b/templates/src/diff/herb_hash_tree.c.erb new file mode 100644 index 000000000..7324dc7ce --- /dev/null +++ b/templates/src/diff/herb_hash_tree.c.erb @@ -0,0 +1,141 @@ +#include "../include/diff/herb_diff.h" + +static herb_hash_T hash_token(herb_hash_T hash, const token_T* token) { + if (token == NULL) { return herb_hash_byte(hash, 0); } + + hash = herb_hash_uint32(hash, (uint32_t) token->type); + hash = herb_hash_string(hash, token->value); + + return hash; +} + +static herb_hash_T hash_child_array(herb_hash_T hash, const hb_array_T* children, herb_hash_map_T* hash_map) { + if (children == NULL) { return herb_hash_byte(hash, 0); } + + const size_t count = hb_array_size(children); + hash = herb_hash_uint64(hash, (uint64_t) count); + + for (size_t index = 0; index < count; index++) { + const AST_NODE_T* child = (const AST_NODE_T*) hb_array_get(children, index); + herb_hash_T child_hash = herb_hash_tree(child, hash_map); + hash = herb_hash_combine(hash, child_hash); + } + + return hash; +} + +herb_hash_T herb_hash_tree(const AST_NODE_T* node, herb_hash_map_T* hash_map) { + if (node == NULL) { return HERB_HASH_INIT; } + + if (herb_hash_map_has(hash_map, node)) { return herb_hash_map_get(hash_map, node); } + + herb_hash_T hash = HERB_HASH_INIT; + hash = herb_hash_uint32(hash, (uint32_t) node->type); + + switch (node->type) { + <%- nodes.each do |node| -%> + case <%= node.type %>: { + <%- hashable_fields = node.fields.reject(&:always_invisible?).reject { |field| field.is_a?(Herb::Template::PrismNodeField) } -%> + <%- if hashable_fields.any? -%> + const <%= node.struct_type %>* <%= node.human %> = (const <%= node.struct_type %>*) node; + <%- hashable_fields.each do |field| -%> + + <%- case field -%> + <%- when Herb::Template::TokenField -%> + hash = hash_token(hash, <%= node.human %>-><%= field.name %>); + <%- when Herb::Template::StringField, Herb::Template::ElementSourceField -%> + hash = herb_hash_string(hash, <%= node.human %>-><%= field.name %>); + <%- when Herb::Template::ArrayField -%> + hash = hash_child_array(hash, <%= node.human %>-><%= field.name %>, hash_map); + <%- when Herb::Template::NodeField, Herb::Template::BorrowedNodeField -%> + hash = herb_hash_combine(hash, herb_hash_tree((const AST_NODE_T*) <%= node.human %>-><%= field.name %>, hash_map)); + <%- when Herb::Template::BooleanField -%> + hash = herb_hash_bool(hash, <%= node.human %>-><%= field.name %>); + <%- when Herb::Template::LocationField -%> + // skip location field: <%= field.name %> + <%- end -%> + <%- end -%> + <%- end -%> + } break; + + <%- end -%> + } + + herb_hash_map_set(hash_map, node, hash); + + return hash; +} + +static herb_hash_T identity_hash_attributes(const hb_array_T* attributes, const herb_hash_map_T* hash_map) { + if (attributes == NULL) { return 0; } + + const size_t count = hb_array_size(attributes); + herb_hash_T combined = 0; + + for (size_t index = 0; index < count; index++) { + const AST_NODE_T* attribute = (const AST_NODE_T*) hb_array_get(attributes, index); + herb_hash_T attribute_hash = herb_hash_map_get(hash_map, attribute); + combined ^= attribute_hash; + } + + return combined; +} + +herb_hash_T herb_hash_node_move_identity(const AST_NODE_T* node, const herb_hash_map_T* hash_map) { + if (node == NULL) { return HERB_HASH_INIT; } + + herb_hash_T hash = HERB_HASH_INIT; + hash = herb_hash_uint32(hash, (uint32_t) node->type); + + switch (node->type) { + case AST_HTML_ELEMENT_NODE: { + const AST_HTML_ELEMENT_NODE_T* element = (const AST_HTML_ELEMENT_NODE_T*) node; + hash = hash_token(hash, element->tag_name); + + if (element->open_tag != NULL) { + const AST_HTML_OPEN_TAG_NODE_T* open_tag = (const AST_HTML_OPEN_TAG_NODE_T*) element->open_tag; + hash = herb_hash_combine(hash, identity_hash_attributes(open_tag->children, hash_map)); + } + } break; + + case AST_HTML_CONDITIONAL_ELEMENT_NODE: { + const AST_HTML_CONDITIONAL_ELEMENT_NODE_T* element = (const AST_HTML_CONDITIONAL_ELEMENT_NODE_T*) node; + hash = hash_token(hash, element->tag_name); + + if (element->open_tag != NULL) { + hash = herb_hash_combine(hash, identity_hash_attributes(element->open_tag->children, hash_map)); + } + } break; + + default: { + hash = herb_hash_map_get(hash_map, node); + } break; + } + + return hash; +} + +herb_hash_T herb_hash_node_identity(const AST_NODE_T* node, const herb_hash_map_T* hash_map) { + if (node == NULL) { return HERB_HASH_INIT; } + + herb_hash_T hash = HERB_HASH_INIT; + hash = herb_hash_uint32(hash, (uint32_t) node->type); + + switch (node->type) { + case AST_HTML_ELEMENT_NODE: { + const AST_HTML_ELEMENT_NODE_T* element = (const AST_HTML_ELEMENT_NODE_T*) node; + hash = hash_token(hash, element->tag_name); + } break; + + case AST_HTML_CONDITIONAL_ELEMENT_NODE: { + const AST_HTML_CONDITIONAL_ELEMENT_NODE_T* element = (const AST_HTML_CONDITIONAL_ELEMENT_NODE_T*) node; + hash = hash_token(hash, element->tag_name); + } break; + + default: { + hash = herb_hash_map_get(hash_map, node); + } break; + } + + return hash; +} diff --git a/test/c/main.c b/test/c/main.c index 0059d53ad..443968e90 100644 --- a/test/c/main.c +++ b/test/c/main.c @@ -14,6 +14,7 @@ TCase *lex_tests(void); TCase *token_tests(void); TCase *util_tests(void); TCase *extract_tests(void); +TCase *diff_tests(void); Suite *herb_suite(void) { Suite *suite = suite_create("Herb Suite"); @@ -31,6 +32,7 @@ Suite *herb_suite(void) { suite_add_tcase(suite, token_tests()); suite_add_tcase(suite, util_tests()); suite_add_tcase(suite, extract_tests()); + suite_add_tcase(suite, diff_tests()); return suite; } diff --git a/test/c/test_diff.c b/test/c/test_diff.c new file mode 100644 index 000000000..824fadbbb --- /dev/null +++ b/test/c/test_diff.c @@ -0,0 +1,238 @@ +#include "include/test.h" + +#include "../../src/include/herb.h" +#include "../../src/include/diff/herb_diff.h" +#include "../../src/include/lib/hb_allocator.h" + +static herb_diff_result_T* diff_sources(const char* old_source, const char* new_source, hb_allocator_T* allocator) { + parser_options_T options = HERB_DEFAULT_PARSER_OPTIONS; + + hb_allocator_T old_allocator; + hb_allocator_T new_allocator; + hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA); + hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA); + + AST_DOCUMENT_NODE_T* old_document = herb_parse(old_source, &options, &old_allocator); + AST_DOCUMENT_NODE_T* new_document = herb_parse(new_source, &options, &new_allocator); + + return herb_diff(old_document, new_document, allocator); +} + +TEST(test_diff_identical_documents) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hello
", "
Hello
", &allocator); + + ck_assert(herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 0); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_text_changed) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hello
", "
World
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_TEXT_CHANGED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_attribute_value_changed) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hi
", "
Hi
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_ATTRIBUTE_VALUE_CHANGED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_attribute_added) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hi
", "
Hi
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_ATTRIBUTE_ADDED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_attribute_removed) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hi
", "
Hi
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_ATTRIBUTE_REMOVED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_node_inserted) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
", "
New
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_NODE_INSERTED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_node_removed) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Old
", "
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_NODE_REMOVED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_erb_content_changed) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("<%= foo %>", "<%= bar %>", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_ERB_CONTENT_CHANGED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_node_moved_with_attributes) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
  • A
  • B
", + "
  • B
  • A
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + bool has_move = false; + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + if (herb_diff_operation_at(result, index)->type == HERB_DIFF_NODE_MOVED) { has_move = true; } + } + ck_assert(has_move); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_plain_reorder_no_move) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
  • A
  • B
", + "
  • B
  • A
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + ck_assert_uint_ne(herb_diff_operation_at(result, index)->type, HERB_DIFF_NODE_MOVED); + } + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_move_with_attribute_change) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
A
B
", + "
B
A
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + bool has_move = false; + bool has_attribute_change = false; + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + if (herb_diff_operation_at(result, index)->type == HERB_DIFF_NODE_MOVED) { has_move = true; } + if (herb_diff_operation_at(result, index)->type == HERB_DIFF_ATTRIBUTE_VALUE_CHANGED) { has_attribute_change = true; } + } + + ck_assert(has_move); + ck_assert(has_attribute_change); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_multiple_changes_with_unchanged_subtree) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
Hello

Keep

", + "
World

Keep

", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 2); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_ATTRIBUTE_VALUE_CHANGED); + ck_assert_uint_eq(herb_diff_operation_at(result, 1)->type, HERB_DIFF_TEXT_CHANGED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_operation_type_to_string) + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_INSERTED), "node_inserted"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_REMOVED), "node_removed"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_REPLACED), "node_replaced"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_MOVED), "node_moved"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_TEXT_CHANGED), "text_changed"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_ERB_CONTENT_CHANGED), "erb_content_changed"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_ATTRIBUTE_ADDED), "attribute_added"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_ATTRIBUTE_REMOVED), "attribute_removed"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_ATTRIBUTE_VALUE_CHANGED), "attribute_value_changed"); + ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_TAG_NAME_CHANGED), "tag_name_changed"); +END + +TCase *diff_tests(void) { + TCase *diff = tcase_create("Diff"); + + tcase_add_test(diff, test_diff_identical_documents); + tcase_add_test(diff, test_diff_text_changed); + tcase_add_test(diff, test_diff_attribute_value_changed); + tcase_add_test(diff, test_diff_attribute_added); + tcase_add_test(diff, test_diff_attribute_removed); + tcase_add_test(diff, test_diff_node_inserted); + tcase_add_test(diff, test_diff_node_removed); + tcase_add_test(diff, test_diff_erb_content_changed); + tcase_add_test(diff, test_diff_node_moved_with_attributes); + tcase_add_test(diff, test_diff_plain_reorder_no_move); + tcase_add_test(diff, test_diff_move_with_attribute_change); + tcase_add_test(diff, test_diff_multiple_changes_with_unchanged_subtree); + tcase_add_test(diff, test_diff_operation_type_to_string); + + return diff; +} diff --git a/test/diff/diff_test.rb b/test/diff/diff_test.rb new file mode 100644 index 000000000..5b38e9807 --- /dev/null +++ b/test/diff/diff_test.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module Diff + class DiffTest < Minitest::Spec + test "returns a DiffResult" do + result = Herb.diff("
Hello
", "
Hello
") + + assert_kind_of Herb::DiffResult, result + end + + test "operations returns DiffOperation instances" do + result = Herb.diff("
Hello
", "
World
") + + assert_kind_of Herb::DiffOperation, result.operations[0] + end + + test "identical documents" do + result = Herb.diff("
Hello
", "
Hello
") + + assert result.identical? + refute result.changed? + assert_equal 0, result.operation_count + end + + test "text changed" do + result = Herb.diff("
Hello
", "
World
") + + refute result.identical? + assert result.changed? + assert_equal 1, result.operation_count + assert_equal :text_changed, result.operations[0].type + end + + test "attribute value changed" do + result = Herb.diff('
Hi
', '
Hi
') + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :attribute_value_changed, result.operations[0].type + end + + test "attribute added" do + result = Herb.diff("
Hi
", '
Hi
') + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :attribute_added, result.operations[0].type + end + + test "attribute removed" do + result = Herb.diff('
Hi
', "
Hi
") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :attribute_removed, result.operations[0].type + end + + test "node inserted" do + result = Herb.diff("
", "
New
") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_inserted, result.operations[0].type + end + + test "node removed" do + result = Herb.diff("
Old
", "
") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_removed, result.operations[0].type + end + + test "erb content changed" do + result = Herb.diff("<%= foo %>", "<%= bar %>") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :erb_content_changed, result.operations[0].type + end + + test "node moved with distinguishing attributes" do + result = Herb.diff( + '
  • A
  • B
', + '
  • B
  • A
' + ) + + refute result.identical? + + types = result.operations.map(&:type) + + assert_includes types, :node_moved + end + + test "plain reorder without attributes reports text changes not moves" do + result = Herb.diff( + "
  • A
  • B
", + "
  • B
  • A
" + ) + + refute result.identical? + + types = result.operations.map(&:type) + + refute_includes types, :node_moved + assert_includes types, :text_changed + end + + test "move with attribute value change" do + result = Herb.diff( + '
A
B
', + '
B
A
' + ) + + refute result.identical? + + types = result.operations.map(&:type) + + assert_includes types, :node_moved + assert_includes types, :attribute_value_changed + end + + test "move with content change" do + result = Herb.diff( + '
Old
B
', + '
B
New
' + ) + + refute result.identical? + + types = result.operations.map(&:type) + + assert_includes types, :node_moved + assert_includes types, :text_changed + end + + test "multiple changes with unchanged subtree" do + result = Herb.diff( + '
Hello

Keep

', + '
World

Keep

' + ) + + refute result.identical? + assert_equal 2, result.operation_count + + types = result.operations.map(&:type) + + assert_includes types, :attribute_value_changed + assert_includes types, :text_changed + end + + test "operations have path" do + result = Herb.diff("
Hello
", "
World
") + + operation = result.operations[0] + + assert_kind_of Array, operation.path + refute_empty operation.path + end + + test "operations have old and new nodes" do + result = Herb.diff("
Hello
", "
World
") + + operation = result.operations[0] + + assert_kind_of Herb::AST::HTMLTextNode, operation.old_node + assert_kind_of Herb::AST::HTMLTextNode, operation.new_node + end + + test "inserted operation has nil old node" do + result = Herb.diff("
", "
New
") + + operation = result.operations[0] + + assert_nil operation.old_node + refute_nil operation.new_node + end + + test "removed operation has nil new node" do + result = Herb.diff("
Old
", "
") + + operation = result.operations[0] + + refute_nil operation.old_node + assert_nil operation.new_node + end + + test "complex erb diff" do + old_source = <<~ERB +
+ <% if current_user %> +

Hello, <%= current_user.name %>

+ <% end %> +
+ ERB + + new_source = <<~ERB +
+ <% if current_user %> +

Hello, <%= current_user.email %>

+ <% end %> +
+ ERB + + result = Herb.diff(old_source, new_source) + + refute result.identical? + + types = result.operations.map(&:type) + + assert_includes types, :attribute_value_changed + assert_includes types, :erb_content_changed + end + + test "wrap with ERB conditional" do + result = Herb.diff("
Content
", "<% if condition? %>
Content
<% end %>") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_wrapped, result.operations[0].type + end + + test "unwrap from ERB conditional" do + result = Herb.diff("<% if condition? %>
Content
<% end %>", "
Content
") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_unwrapped, result.operations[0].type + end + + test "wrap with HTML element" do + result = Herb.diff("
Hello
", "

Hello

") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_wrapped, result.operations[0].type + end + + test "unwrap from HTML element" do + result = Herb.diff("

Text

", "

Text

") + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_unwrapped, result.operations[0].type + end + + test "wrap with content change is not detected as wrap" do + result = Herb.diff("
Old
", "<% if x %>
New
<% end %>") + + refute result.identical? + + types = result.operations.map(&:type) + + refute_includes types, :node_wrapped + end + + test "wrapped operation has old and new nodes" do + result = Herb.diff("
Content
", "<% if condition? %>
Content
<% end %>") + + operation = result.operations[0] + + assert_kind_of Herb::AST::HTMLElementNode, operation.old_node + assert_kind_of Herb::AST::ERBIfNode, operation.new_node + end + + test "to_hash serialization" do + result = Herb.diff("
Hello
", "
World
") + + hash = result.to_hash + + assert_kind_of Hash, hash + assert_equal false, hash[:identical] + assert_kind_of Array, hash[:operations] + assert_equal :text_changed, hash[:operations][0][:type] + end + + test "inspect output" do + identical_result = Herb.diff("
Same
", "
Same
") + assert_match(/identical/, identical_result.inspect) + + changed_result = Herb.diff("
Hello
", "
World
") + assert_match(/1 operation/, changed_result.inspect) + end + end +end diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index c1605ee20..ca5786baa 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -5,6 +5,7 @@ #include #include "extension_helpers.h" +#include "nodes.h" extern "C" { #include "../src/include/lib/hb_allocator.h" @@ -15,6 +16,7 @@ extern "C" { #include "../src/include/lib/hb_buffer.h" #include "../src/include/extract.h" #include "../src/include/herb.h" +#include "../src/include/diff/herb_diff.h" #include "../src/include/location/location.h" #include "../src/include/location/position.h" #include "../src/include/ast/pretty_print.h" @@ -177,6 +179,72 @@ val Herb_parse_ruby(const std::string& source) { return result; } +val Herb_diff(const std::string& old_source, const std::string& new_source) { + hb_allocator_T old_allocator; + hb_allocator_T new_allocator; + hb_allocator_T diff_allocator; + + if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } + if (!hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } + if (!hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } + + parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; + + AST_DOCUMENT_NODE_T* old_root = herb_parse(old_source.c_str(), &parser_options, &old_allocator); + AST_DOCUMENT_NODE_T* new_root = herb_parse(new_source.c_str(), &parser_options, &new_allocator); + + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); + + val result = val::object(); + result.set("identical", diff_result->trees_identical); + + size_t operation_count = herb_diff_operation_count(diff_result); + val operations = val::array(); + + for (size_t index = 0; index < operation_count; index++) { + const herb_diff_operation_T* operation = herb_diff_operation_at(diff_result, index); + + val operation_object = val::object(); + val path = val::array(); + + operation_object.set("type", std::string(herb_diff_operation_type_to_string(operation->type))); + + for (uint16_t path_index = 0; path_index < operation->path.depth; path_index++) { + path.call("push", operation->path.indices[path_index]); + } + + operation_object.set("path", path); + + if (operation->old_node != NULL) { + operation_object.set("oldNode", NodeFromCStruct((AST_NODE_T*) operation->old_node)); + } else { + operation_object.set("oldNode", val::null()); + } + + if (operation->new_node != NULL) { + operation_object.set("newNode", NodeFromCStruct((AST_NODE_T*) operation->new_node)); + } else { + operation_object.set("newNode", val::null()); + } + + operation_object.set("oldIndex", operation->old_index); + operation_object.set("newIndex", operation->new_index); + + operations.call("push", operation_object); + } + + result.set("operations", operations); + + ast_node_free((AST_NODE_T*) old_root, &old_allocator); + ast_node_free((AST_NODE_T*) new_root, &new_allocator); + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + return result; +} + std::string Herb_version() { const char* libherb_version = herb_version(); const char* libprism_version = herb_prism_version(); @@ -192,4 +260,5 @@ EMSCRIPTEN_BINDINGS(herb_module) { function("extractHTML", &Herb_extract_html); function("version", &Herb_version); function("parseRuby", &Herb_parse_ruby); + function("diff", &Herb_diff); } From 563fb5e59d006c716829a36e43a01b97f9a03bdd Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 14:04:30 +0900 Subject: [PATCH 2/9] Cleanup --- .rubocop.yml | 1 + ext/herb/extension.c | 22 +++- java/herb_jni.c | 40 ++++++- javascript/packages/core/src/diff-result.ts | 16 ++- javascript/packages/node/extension/herb.cpp | 17 +++ lib/herb/diff_operation.rb | 26 ++--- lib/herb/diff_result.rb | 13 ++- rust/src/herb.rs | 12 +++ sig/herb/diff_operation.rbs | 26 +++-- sig/herb/diff_result.rbs | 9 ++ src/diff/herb_diff.c | 23 ++-- templates/rust/src/ast/nodes.rs.erb | 2 +- templates/src/diff/herb_hash_tree.c.erb | 4 +- test/c/test_diff.c | 112 ++++++++++++++++++++ test/diff/diff_test.rb | 71 +++++++++++++ wasm/herb-wasm.cpp | 30 +++++- 16 files changed, 376 insertions(+), 48 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 48c105aca..ac64eb380 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -128,6 +128,7 @@ Metrics/ParameterLists: Exclude: - lib/herb/ast/node.rb - lib/herb/ast/nodes.rb + - lib/herb/diff_operation.rb - lib/herb/engine/validators/security_validator.rb - lib/herb/errors.rb - lib/herb/parser_options.rb diff --git a/ext/herb/extension.c b/ext/herb/extension.c index a7834831b..41c82d773 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -485,11 +485,29 @@ static VALUE Herb_diff(int argc, VALUE* argv, VALUE self) { parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; if (!hb_allocator_init(&args.old_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } - if (!hb_allocator_init(&args.new_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } - if (!hb_allocator_init(&args.diff_allocator, HB_ALLOCATOR_ARENA)) { return Qnil; } + + if (!hb_allocator_init(&args.new_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&args.old_allocator); + + return Qnil; + } + + if (!hb_allocator_init(&args.diff_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&args.old_allocator); + hb_allocator_destroy(&args.new_allocator); + + return Qnil; + } args.old_root = herb_parse(old_string, &parser_options, &args.old_allocator); args.new_root = herb_parse(new_string, &parser_options, &args.new_allocator); + + if (args.old_root == NULL || args.new_root == NULL) { + diff_cleanup((VALUE) &args); + + return Qnil; + } + args.diff_result = herb_diff(args.old_root, args.new_root, &args.diff_allocator); return rb_ensure(diff_convert_body, (VALUE) &args, diff_cleanup, (VALUE) &args); diff --git a/java/herb_jni.c b/java/herb_jni.c index de0c1c640..495e5185e 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -265,11 +265,28 @@ Java_org_herb_Herb_diff(JNIEnv* env, jclass clazz, jstring old_source, jstring n hb_allocator_T new_allocator; hb_allocator_T diff_allocator; - if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA) - || !hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA) - || !hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { + if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA)) { (*env)->ReleaseStringUTFChars(env, old_source, old_src); (*env)->ReleaseStringUTFChars(env, new_source, new_src); + + return NULL; + } + + if (!hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&old_allocator); + (*env)->ReleaseStringUTFChars(env, old_source, old_src); + (*env)->ReleaseStringUTFChars(env, new_source, new_src); + + return NULL; + } + + if (!hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + (*env)->ReleaseStringUTFChars(env, old_source, old_src); + (*env)->ReleaseStringUTFChars(env, new_source, new_src); + return NULL; } @@ -277,6 +294,21 @@ Java_org_herb_Herb_diff(JNIEnv* env, jclass clazz, jstring old_source, jstring n AST_DOCUMENT_NODE_T* old_root = herb_parse(old_src, &parser_options, &old_allocator); AST_DOCUMENT_NODE_T* new_root = herb_parse(new_src, &parser_options, &new_allocator); + + if (old_root == NULL || new_root == NULL) { + if (old_root != NULL) { ast_node_free((AST_NODE_T*) old_root, &old_allocator); } + if (new_root != NULL) { ast_node_free((AST_NODE_T*) new_root, &new_allocator); } + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + (*env)->ReleaseStringUTFChars(env, old_source, old_src); + (*env)->ReleaseStringUTFChars(env, new_source, new_src); + + return NULL; + } + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); jclass diff_result_class = (*env)->FindClass(env, "org/herb/DiffResult"); @@ -302,7 +334,7 @@ Java_org_herb_Herb_diff(JNIEnv* env, jclass clazz, jstring old_source, jstring n for (uint16_t path_index = 0; path_index < operation->path.depth; path_index++) { path_elements[path_index] = (jint) operation->path.indices[path_index]; } - + (*env)->ReleaseIntArrayElements(env, path_array, path_elements, 0); jobject old_node = operation->old_node != NULL ? CreateASTNode(env, (AST_NODE_T*) operation->old_node) : NULL; diff --git a/javascript/packages/core/src/diff-result.ts b/javascript/packages/core/src/diff-result.ts index 5524c1e9c..11b2455d1 100644 --- a/javascript/packages/core/src/diff-result.ts +++ b/javascript/packages/core/src/diff-result.ts @@ -1,7 +1,21 @@ import type { SerializedNode } from "./nodes/index.js" +export type DiffOperationType = + | "attribute_added" + | "attribute_removed" + | "attribute_value_changed" + | "erb_content_changed" + | "node_inserted" + | "node_moved" + | "node_removed" + | "node_replaced" + | "node_unwrapped" + | "node_wrapped" + | "tag_name_changed" + | "text_changed" + export interface DiffOperation { - type: string + type: DiffOperationType path: number[] oldNode: SerializedNode | null newNode: SerializedNode | null diff --git a/javascript/packages/node/extension/herb.cpp b/javascript/packages/node/extension/herb.cpp index c314f398d..f94c87928 100644 --- a/javascript/packages/node/extension/herb.cpp +++ b/javascript/packages/node/extension/herb.cpp @@ -386,6 +386,23 @@ napi_value Herb_diff(napi_env env, napi_callback_info info) { AST_DOCUMENT_NODE_T* old_root = herb_parse(old_string, &parser_options, &old_allocator); AST_DOCUMENT_NODE_T* new_root = herb_parse(new_string, &parser_options, &new_allocator); + + if (old_root == nullptr || new_root == nullptr) { + if (old_root != nullptr) { ast_node_free((AST_NODE_T*) old_root, &old_allocator); } + if (new_root != nullptr) { ast_node_free((AST_NODE_T*) new_root, &new_allocator); } + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + free(old_string); + free(new_string); + + napi_throw_error(env, nullptr, "Failed to parse source"); + + return nullptr; + } + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); napi_value result; diff --git a/lib/herb/diff_operation.rb b/lib/herb/diff_operation.rb index 8b1a9dc84..ae60f061a 100644 --- a/lib/herb/diff_operation.rb +++ b/lib/herb/diff_operation.rb @@ -2,24 +2,16 @@ # typed: true module Herb - class DiffOperation - attr_reader :type #: Symbol - attr_reader :path #: Array[Integer] - attr_reader :old_node #: Herb::AST::Node? - attr_reader :new_node #: Herb::AST::Node? - attr_reader :old_index #: Integer - attr_reader :new_index #: Integer - - #: (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void - def initialize(type, path, old_node, new_node, old_index, new_index) - @type = type - @path = path - @old_node = old_node - @new_node = new_node - @old_index = old_index - @new_index = new_index - end + DiffOperation = Data.define( + :type, #: Symbol + :path, #: Array[Integer] + :old_node, #: Herb::AST::Node? + :new_node, #: Herb::AST::Node? + :old_index, #: Integer + :new_index #: Integer + ) + class DiffOperation #: () -> Hash[Symbol, untyped] def to_hash { diff --git a/lib/herb/diff_result.rb b/lib/herb/diff_result.rb index 2f0ca9a4c..c4751f219 100644 --- a/lib/herb/diff_result.rb +++ b/lib/herb/diff_result.rb @@ -3,12 +3,23 @@ module Herb class DiffResult + include Enumerable #[Herb::DiffOperation] + attr_reader :operations #: Array[Herb::DiffOperation] #: (bool, Array[Herb::DiffOperation]) -> void def initialize(identical, operations) @identical = identical - @operations = operations + @operations = operations.freeze + freeze + end + + #: () { (Herb::DiffOperation) -> void } -> void + #: () -> Enumerator[Herb::DiffOperation, void] + def each(&block) + return operations.each unless block + + operations.each(&block) end #: () -> bool diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 456f4354e..f6467500a 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -336,6 +336,7 @@ pub fn diff(old_source: &str, new_source: &str) -> Result { prism_nodes: false, prism_nodes_deep: false, dot_notation_tags: false, + transform_conditionals: false, html: true, start_line: 0, start_column: 0, @@ -344,6 +345,17 @@ pub fn diff(old_source: &str, new_source: &str) -> Result { let old_root = crate::ffi::herb_parse(old_c_source.as_ptr(), &parser_options, &mut old_allocator); let new_root = crate::ffi::herb_parse(new_c_source.as_ptr(), &parser_options, &mut new_allocator); + if old_root.is_null() || new_root.is_null() { + if !old_root.is_null() { crate::ffi::ast_node_free(old_root as *mut AST_NODE_T, &mut old_allocator); } + if !new_root.is_null() { crate::ffi::ast_node_free(new_root as *mut AST_NODE_T, &mut new_allocator); } + + crate::ffi::hb_allocator_destroy(&mut diff_allocator); + crate::ffi::hb_allocator_destroy(&mut old_allocator); + crate::ffi::hb_allocator_destroy(&mut new_allocator); + + return Err("Failed to parse source".to_string()); + } + let diff_result = crate::ffi::herb_diff(old_root, new_root, &mut diff_allocator); let identical = crate::ffi::herb_diff_trees_identical(diff_result); diff --git a/sig/herb/diff_operation.rbs b/sig/herb/diff_operation.rbs index 36a0c13ed..b2d448caf 100644 --- a/sig/herb/diff_operation.rbs +++ b/sig/herb/diff_operation.rbs @@ -1,25 +1,33 @@ # Generated from lib/herb/diff_operation.rb with RBS::Inline module Herb - class DiffOperation - attr_reader type: Symbol + class DiffOperation < Data + attr_reader type(): Symbol + + attr_reader path(): Array[Integer] + + attr_reader old_node(): Herb::AST::Node? - attr_reader path: Array[Integer] + attr_reader new_node(): Herb::AST::Node? - attr_reader old_node: Herb::AST::Node? + attr_reader old_index(): Integer - attr_reader new_node: Herb::AST::Node? + attr_reader new_index(): Integer - attr_reader old_index: Integer + def self.new: (Symbol type, Array[Integer] path, Herb::AST::Node? old_node, Herb::AST::Node? new_node, Integer old_index, Integer new_index) -> instance + | (type: Symbol, path: Array[Integer], old_node: Herb::AST::Node?, new_node: Herb::AST::Node?, old_index: Integer, new_index: Integer) -> instance - attr_reader new_index: Integer + def self.members: () -> [ :type, :path, :old_node, :new_node, :old_index, :new_index ] - # : (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void - def initialize: (Symbol, Array[Integer], Herb::AST::Node?, Herb::AST::Node?, Integer, Integer) -> void + def members: () -> [ :type, :path, :old_node, :new_node, :old_index, :new_index ] + end + class DiffOperation # : () -> Hash[Symbol, untyped] def to_hash: () -> Hash[Symbol, untyped] + alias to_h to_hash + # : () -> String def inspect: () -> String end diff --git a/sig/herb/diff_result.rbs b/sig/herb/diff_result.rbs index 4222f688e..8d3a71e72 100644 --- a/sig/herb/diff_result.rbs +++ b/sig/herb/diff_result.rbs @@ -2,11 +2,18 @@ module Herb class DiffResult + include Enumerable[Herb::DiffOperation] + attr_reader operations: Array[Herb::DiffOperation] # : (bool, Array[Herb::DiffOperation]) -> void def initialize: (bool, Array[Herb::DiffOperation]) -> void + # : () { (Herb::DiffOperation) -> void } -> void + # : () -> Enumerator[Herb::DiffOperation, void] + def each: () { (Herb::DiffOperation) -> void } -> void + | () -> Enumerator[Herb::DiffOperation, void] + # : () -> bool def identical?: () -> bool @@ -19,6 +26,8 @@ module Herb # : () -> Hash[Symbol, untyped] def to_hash: () -> Hash[Symbol, untyped] + alias to_h to_hash + # : () -> String def inspect: () -> String end diff --git a/src/diff/herb_diff.c b/src/diff/herb_diff.c index f477530ac..443719ccb 100644 --- a/src/diff/herb_diff.c +++ b/src/diff/herb_diff.c @@ -1,8 +1,12 @@ #include "../include/diff/herb_diff.h" +#include + herb_diff_path_T herb_diff_path_empty(void) { herb_diff_path_T path; + path.depth = 0; + return path; } @@ -12,6 +16,8 @@ herb_diff_path_T herb_diff_path_append(const herb_diff_path_T path, const uint32 if (result.depth < HERB_DIFF_PATH_MAX_DEPTH) { result.indices[result.depth] = index; result.depth++; + } else { + fprintf(stderr, "herb: diff path depth exceeded maximum of %d\n", HERB_DIFF_PATH_MAX_DEPTH); } return result; @@ -51,6 +57,7 @@ herb_diff_result_T* herb_diff( herb_hash_map_T old_hashes; herb_hash_map_T new_hashes; + herb_hash_map_init(&old_hashes, 256, allocator); herb_hash_map_init(&new_hashes, 256, allocator); @@ -96,18 +103,18 @@ bool herb_diff_trees_identical(const herb_diff_result_T* result) { const char* herb_diff_operation_type_to_string(const herb_diff_operation_type_T type) { switch (type) { - case HERB_DIFF_NODE_INSERTED: return "node_inserted"; - case HERB_DIFF_NODE_REMOVED: return "node_removed"; - case HERB_DIFF_NODE_REPLACED: return "node_replaced"; - case HERB_DIFF_NODE_MOVED: return "node_moved"; - case HERB_DIFF_TEXT_CHANGED: return "text_changed"; - case HERB_DIFF_ERB_CONTENT_CHANGED: return "erb_content_changed"; case HERB_DIFF_ATTRIBUTE_ADDED: return "attribute_added"; case HERB_DIFF_ATTRIBUTE_REMOVED: return "attribute_removed"; case HERB_DIFF_ATTRIBUTE_VALUE_CHANGED: return "attribute_value_changed"; - case HERB_DIFF_TAG_NAME_CHANGED: return "tag_name_changed"; - case HERB_DIFF_NODE_WRAPPED: return "node_wrapped"; + case HERB_DIFF_ERB_CONTENT_CHANGED: return "erb_content_changed"; + case HERB_DIFF_NODE_INSERTED: return "node_inserted"; + case HERB_DIFF_NODE_MOVED: return "node_moved"; + case HERB_DIFF_NODE_REMOVED: return "node_removed"; + case HERB_DIFF_NODE_REPLACED: return "node_replaced"; case HERB_DIFF_NODE_UNWRAPPED: return "node_unwrapped"; + case HERB_DIFF_NODE_WRAPPED: return "node_wrapped"; + case HERB_DIFF_TAG_NAME_CHANGED: return "tag_name_changed"; + case HERB_DIFF_TEXT_CHANGED: return "text_changed"; } return "unknown"; diff --git a/templates/rust/src/ast/nodes.rs.erb b/templates/rust/src/ast/nodes.rs.erb index f38e2a907..4a9428d34 100644 --- a/templates/rust/src/ast/nodes.rs.erb +++ b/templates/rust/src/ast/nodes.rs.erb @@ -160,7 +160,7 @@ macro_rules! convert_specific_node_field { }; } -unsafe fn convert_node(node_pointer: *const c_void) -> Option { +pub(crate) unsafe fn convert_node(node_pointer: *const c_void) -> Option { if node_pointer.is_null() { return None; } diff --git a/templates/src/diff/herb_hash_tree.c.erb b/templates/src/diff/herb_hash_tree.c.erb index 7324dc7ce..8b5f48fab 100644 --- a/templates/src/diff/herb_hash_tree.c.erb +++ b/templates/src/diff/herb_hash_tree.c.erb @@ -67,10 +67,10 @@ herb_hash_T herb_hash_tree(const AST_NODE_T* node, herb_hash_map_T* hash_map) { } static herb_hash_T identity_hash_attributes(const hb_array_T* attributes, const herb_hash_map_T* hash_map) { - if (attributes == NULL) { return 0; } + if (attributes == NULL) { return HERB_HASH_INIT; } const size_t count = hb_array_size(attributes); - herb_hash_T combined = 0; + herb_hash_T combined = HERB_HASH_INIT; for (size_t index = 0; index < count; index++) { const AST_NODE_T* attribute = (const AST_NODE_T*) hb_array_get(attributes, index); diff --git a/test/c/test_diff.c b/test/c/test_diff.c index 824fadbbb..3a733b7db 100644 --- a/test/c/test_diff.c +++ b/test/c/test_diff.c @@ -204,6 +204,110 @@ TEST(test_diff_multiple_changes_with_unchanged_subtree) hb_allocator_destroy(&allocator); END +TEST(test_diff_node_wrapped) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Content
", "<% if condition? %>
Content
<% end %>", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_NODE_WRAPPED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_node_unwrapped) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("<% if condition? %>
Content
<% end %>", "
Content
", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_NODE_UNWRAPPED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_node_wrapped_html) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hello
", "

Hello

", &allocator); + + ck_assert(!herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + ck_assert_uint_eq(herb_diff_operation_at(result, 0)->type, HERB_DIFF_NODE_WRAPPED); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_operation_path) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hello
", "
World
", &allocator); + + ck_assert_uint_eq(herb_diff_operation_count(result), 1); + + const herb_diff_operation_T* operation = herb_diff_operation_at(result, 0); + ck_assert_uint_gt(operation->path.depth, 0); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_operation_nodes) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Hello
", "
World
", &allocator); + + const herb_diff_operation_T* operation = herb_diff_operation_at(result, 0); + ck_assert_ptr_nonnull(operation->old_node); + ck_assert_ptr_nonnull(operation->new_node); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_inserted_has_null_old_node) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
", "
New
", &allocator); + + const herb_diff_operation_T* operation = herb_diff_operation_at(result, 0); + ck_assert_ptr_null(operation->old_node); + ck_assert_ptr_nonnull(operation->new_node); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_removed_has_null_new_node) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("
Old
", "
", &allocator); + + const herb_diff_operation_T* operation = herb_diff_operation_at(result, 0); + ck_assert_ptr_nonnull(operation->old_node); + ck_assert_ptr_null(operation->new_node); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_empty_documents) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources("", "", &allocator); + + ck_assert(herb_diff_trees_identical(result)); + ck_assert_uint_eq(herb_diff_operation_count(result), 0); + + hb_allocator_destroy(&allocator); +END + TEST(test_diff_operation_type_to_string) ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_INSERTED), "node_inserted"); ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_REMOVED), "node_removed"); @@ -232,6 +336,14 @@ TCase *diff_tests(void) { tcase_add_test(diff, test_diff_plain_reorder_no_move); tcase_add_test(diff, test_diff_move_with_attribute_change); tcase_add_test(diff, test_diff_multiple_changes_with_unchanged_subtree); + tcase_add_test(diff, test_diff_node_wrapped); + tcase_add_test(diff, test_diff_node_unwrapped); + tcase_add_test(diff, test_diff_node_wrapped_html); + tcase_add_test(diff, test_diff_operation_path); + tcase_add_test(diff, test_diff_operation_nodes); + tcase_add_test(diff, test_diff_inserted_has_null_old_node); + tcase_add_test(diff, test_diff_removed_has_null_new_node); + tcase_add_test(diff, test_diff_empty_documents); tcase_add_test(diff, test_diff_operation_type_to_string); return diff; diff --git a/test/diff/diff_test.rb b/test/diff/diff_test.rb index 5b38e9807..7b43e37bb 100644 --- a/test/diff/diff_test.rb +++ b/test/diff/diff_test.rb @@ -283,5 +283,76 @@ class DiffTest < Minitest::Spec changed_result = Herb.diff("
Hello
", "
World
") assert_match(/1 operation/, changed_result.inspect) end + + test "DiffResult is Enumerable" do + result = Herb.diff("
Hello
", "
World
") + + assert_kind_of Enumerable, result + + types = result.map(&:type) + assert_includes types, :text_changed + end + + test "DiffResult#each yields operations" do + result = Herb.diff("
Hello
", "
World
") + + operations = [] + result.each { |op| operations << op } + + assert_equal result.operations.size, operations.size + assert_equal result.operations[0], operations[0] + end + + test "DiffResult#each returns enumerator without block" do + result = Herb.diff("
Hello
", "
World
") + + enumerator = result.each + assert_kind_of Enumerator, enumerator + end + + test "DiffOperation equality" do + result = Herb.diff("
Hello
", "
World
") + + op = result.operations[0] + + assert_equal op.type, :text_changed + assert_kind_of Array, op.path + assert_equal op, op + end + + test "DiffOperation is frozen" do + result = Herb.diff("
Hello
", "
World
") + + assert_predicate result.operations[0], :frozen? + end + + test "DiffResult is frozen" do + result = Herb.diff("
Hello
", "
World
") + + assert_predicate result, :frozen? + end + + test "DiffOperation is a Data class" do + result = Herb.diff("
Hello
", "
World
") + + assert_kind_of Data, result.operations[0] + end + + test "empty documents are identical" do + result = Herb.diff("", "") + + assert_predicate result, :identical? + assert_equal 0, result.operation_count + end + + test "different top-level elements are removed and inserted" do + result = Herb.diff("
Hello
", "Hello") + + refute result.identical? + + types = result.map(&:type) + assert_includes types, :node_removed + assert_includes types, :node_inserted + end end end diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index 7b9d7e916..7f42f7a7b 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -188,15 +188,39 @@ val Herb_diff(const std::string& old_source, const std::string& new_source) { hb_allocator_T new_allocator; hb_allocator_T diff_allocator; - if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } - if (!hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } - if (!hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { return val::null(); } + if (!hb_allocator_init(&old_allocator, HB_ALLOCATOR_ARENA)) { + return val::null(); + } + + if (!hb_allocator_init(&new_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&old_allocator); + + return val::null(); + } + + if (!hb_allocator_init(&diff_allocator, HB_ALLOCATOR_ARENA)) { + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + return val::null(); + } parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; AST_DOCUMENT_NODE_T* old_root = herb_parse(old_source.c_str(), &parser_options, &old_allocator); AST_DOCUMENT_NODE_T* new_root = herb_parse(new_source.c_str(), &parser_options, &new_allocator); + if (old_root == nullptr || new_root == nullptr) { + if (old_root != nullptr) { ast_node_free((AST_NODE_T*) old_root, &old_allocator); } + if (new_root != nullptr) { ast_node_free((AST_NODE_T*) new_root, &new_allocator); } + + hb_allocator_destroy(&diff_allocator); + hb_allocator_destroy(&old_allocator); + hb_allocator_destroy(&new_allocator); + + return val::null(); + } + herb_diff_result_T* diff_result = herb_diff(old_root, new_root, &diff_allocator); val result = val::object(); From 037764f574638ff1c054df3b8e556c39bb4b7663 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 14:13:23 +0900 Subject: [PATCH 3/9] Cleanup --- ext/herb/extension.c | 16 +++++++++++----- rust/src/herb.rs | 9 +++++++-- test/diff/diff_test.rb | 7 +++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 41c82d773..59d2f4b55 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -434,11 +434,17 @@ static VALUE rb_create_diff_operation(const herb_diff_operation_T* operation) { VALUE old_node = operation->old_node != NULL ? rb_node_from_c_struct((AST_NODE_T*) operation->old_node) : Qnil; VALUE new_node = operation->new_node != NULL ? rb_node_from_c_struct((AST_NODE_T*) operation->new_node) : Qnil; - VALUE args[] = { - type, path_array, old_node, new_node, UINT2NUM(operation->old_index), UINT2NUM(operation->new_index) - }; - - return rb_class_new_instance(6, args, cDiffOperation); + return rb_funcall( + cDiffOperation, + rb_intern("new"), + 6, + type, + path_array, + old_node, + new_node, + UINT2NUM(operation->old_index), + UINT2NUM(operation->new_index) + ); } static VALUE diff_convert_body(VALUE arg) { diff --git a/rust/src/herb.rs b/rust/src/herb.rs index f6467500a..34c2003fb 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -346,8 +346,13 @@ pub fn diff(old_source: &str, new_source: &str) -> Result { let new_root = crate::ffi::herb_parse(new_c_source.as_ptr(), &parser_options, &mut new_allocator); if old_root.is_null() || new_root.is_null() { - if !old_root.is_null() { crate::ffi::ast_node_free(old_root as *mut AST_NODE_T, &mut old_allocator); } - if !new_root.is_null() { crate::ffi::ast_node_free(new_root as *mut AST_NODE_T, &mut new_allocator); } + if !old_root.is_null() { + crate::ffi::ast_node_free(old_root as *mut AST_NODE_T, &mut old_allocator); + } + + if !new_root.is_null() { + crate::ffi::ast_node_free(new_root as *mut AST_NODE_T, &mut new_allocator); + } crate::ffi::hb_allocator_destroy(&mut diff_allocator); crate::ffi::hb_allocator_destroy(&mut old_allocator); diff --git a/test/diff/diff_test.rb b/test/diff/diff_test.rb index 7b43e37bb..620a082b0 100644 --- a/test/diff/diff_test.rb +++ b/test/diff/diff_test.rb @@ -296,11 +296,10 @@ class DiffTest < Minitest::Spec test "DiffResult#each yields operations" do result = Herb.diff("
Hello
", "
World
") - operations = [] - result.each { |op| operations << op } + count = 0 + result.each { count += 1 } - assert_equal result.operations.size, operations.size - assert_equal result.operations[0], operations[0] + assert_equal result.operations.size, count end test "DiffResult#each returns enumerator without block" do From f1a696876e6014aece28f1a345fe4c18606a53e9 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 14:41:49 +0900 Subject: [PATCH 4/9] Update `.clang-format-ignore --- .clang-format-ignore | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.clang-format-ignore b/.clang-format-ignore index 9778be4ea..20de5ae48 100644 --- a/.clang-format-ignore +++ b/.clang-format-ignore @@ -1,17 +1,20 @@ ext/herb/error_helpers.c ext/herb/nodes.c +src/analyze/action_view/generated_handlers.c +src/analyze/action_view/generated_handlers.h +src/analyze/action_view/helper_registry.c src/analyze/missing_end.c src/analyze/transform.c src/ast/ast_nodes.c src/ast/ast_pretty_print.c +src/diff/herb_diff_helpers.c +src/diff/herb_diff_nodes.c +src/diff/herb_hash_tree.c src/errors.c +src/include/analyze/action_view/helper_registry.h src/include/ast/ast_nodes.h src/include/ast/ast_pretty_print.h src/include/errors.h src/include/lib/hb_foreach.h src/parser/match_tags.c -src/analyze/action_view/helper_registry.c -src/analyze/action_view/generated_handlers.c -src/analyze/action_view/generated_handlers.h -src/include/analyze/action_view/helper_registry.h src/visitor.c From d4c31679c74b35474517dbb7db8cfd9b7efb87e2 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 15:30:09 +0900 Subject: [PATCH 5/9] Optimize move detection with hash-indexed lookups --- javascript/packages/node/binding.gyp | 5 +- src/diff/herb_diff_children.c | 73 +++++++++------ src/diff/herb_hash_index_map.c | 47 ++++++++++ src/include/diff/herb_hash_index_map.h | 32 +++++++ test/c/test_diff.c | 118 ++++++++++++++++++++++++ test/diff/diff_test.rb | 121 +++++++++++++++++++++++++ 6 files changed, 364 insertions(+), 32 deletions(-) create mode 100644 src/diff/herb_hash_index_map.c create mode 100644 src/include/diff/herb_hash_index_map.h diff --git a/javascript/packages/node/binding.gyp b/javascript/packages/node/binding.gyp index f25a6ee41..8d6b09c83 100644 --- a/javascript/packages/node/binding.gyp +++ b/javascript/packages/node/binding.gyp @@ -21,13 +21,14 @@ "./extension/libherb/analyze/action_view/tag_helper_node_builders.c", "./extension/libherb/analyze/action_view/tag_helpers.c", "./extension/libherb/analyze/action_view/turbo_frame_tag.c", - "./extension/libherb/diff/herb_diff.c", "./extension/libherb/diff/herb_diff_attributes.c", "./extension/libherb/diff/herb_diff_children.c", "./extension/libherb/diff/herb_diff_nodes.c", - "./extension/libherb/diff/herb_hash.c", + "./extension/libherb/diff/herb_diff.c", + "./extension/libherb/diff/herb_hash_index_map.c", "./extension/libherb/diff/herb_hash_map.c", "./extension/libherb/diff/herb_hash_tree.c", + "./extension/libherb/diff/herb_hash.c", "./extension/libherb/analyze/analyze_helpers.c", "./extension/libherb/analyze/analyze.c", "./extension/libherb/analyze/analyzed_ruby.c", diff --git a/src/diff/herb_diff_children.c b/src/diff/herb_diff_children.c index f070ec7c7..b97bb19a4 100644 --- a/src/diff/herb_diff_children.c +++ b/src/diff/herb_diff_children.c @@ -1,4 +1,5 @@ #include "../include/diff/herb_diff.h" +#include "../include/diff/herb_hash_index_map.h" #include "../include/macros.h" #include @@ -304,6 +305,17 @@ void herb_diff_children( } } + herb_hash_index_map_T move_identity_map; + herb_hash_index_map_init(&move_identity_map, insert_count, result->allocator); + + for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { + const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); + + herb_hash_T new_identity = herb_hash_node_move_identity(new_child, new_hashes); + herb_hash_index_map_insert(&move_identity_map, new_identity, insert_index); + } + for (size_t remove_index = 0; remove_index < remove_count; remove_index++) { if (remove_matched[remove_index]) { continue; } @@ -311,51 +323,51 @@ void herb_diff_children( const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); herb_hash_T old_identity = herb_hash_node_move_identity(old_child, old_hashes); - for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { - if (insert_matched[insert_index]) { continue; } - - const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; - const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); - herb_hash_T new_identity = herb_hash_node_move_identity(new_child, new_hashes); + size_t matched_insert; - if (old_identity == new_identity) { - remove_matched[remove_index] = true; - insert_matched[insert_index] = true; + if (herb_hash_index_map_find_unmatched(&move_identity_map, old_identity, insert_matched, &matched_insert)) { + const edit_entry_T* insert_entry = &edit_script[insert_indices[matched_insert]]; - edit_script[remove_indices[remove_index]].type = EDIT_MOVE; - edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; - edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + remove_matched[remove_index] = true; + insert_matched[matched_insert] = true; - break; - } + edit_script[remove_indices[remove_index]].type = EDIT_MOVE; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[matched_insert]].type = EDIT_CONSUMED; } } + herb_hash_index_map_T tag_identity_map; + herb_hash_index_map_init(&tag_identity_map, insert_count, result->allocator); + + for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { + if (insert_matched[insert_index]) { continue; } + + const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; + const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); + + herb_hash_T new_tag_identity = herb_hash_node_identity(new_child, new_hashes); + herb_hash_index_map_insert(&tag_identity_map, new_tag_identity, insert_index); + } + for (size_t remove_index = 0; remove_index < remove_count; remove_index++) { if (remove_matched[remove_index]) { continue; } const edit_entry_T* remove_entry = &edit_script[remove_indices[remove_index]]; const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); - herb_hash_T old_tag_identity = herb_hash_node_identity(old_child, old_hashes); - - for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { - if (insert_matched[insert_index]) { continue; } - - const edit_entry_T* insert_entry = &edit_script[insert_indices[insert_index]]; - const AST_NODE_T* new_child = (const AST_NODE_T*) hb_array_get(new_children, insert_entry->new_index); - herb_hash_T new_tag_identity = herb_hash_node_identity(new_child, new_hashes); + herb_hash_T old_tag_identity = herb_hash_node_identity(old_child, old_hashes); + size_t matched_insert; - if (old_tag_identity == new_tag_identity) { - remove_matched[remove_index] = true; - insert_matched[insert_index] = true; + if (herb_hash_index_map_find_unmatched(&tag_identity_map, old_tag_identity, insert_matched, &matched_insert)) { + const edit_entry_T* insert_entry = &edit_script[insert_indices[matched_insert]]; - edit_script[remove_indices[remove_index]].type = EDIT_COALESCED_KEEP; - edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; - edit_script[insert_indices[insert_index]].type = EDIT_CONSUMED; + remove_matched[remove_index] = true; + insert_matched[matched_insert] = true; - break; - } + edit_script[remove_indices[remove_index]].type = EDIT_COALESCED_KEEP; + edit_script[remove_indices[remove_index]].new_index = insert_entry->new_index; + edit_script[insert_indices[matched_insert]].type = EDIT_CONSUMED; } } @@ -364,6 +376,7 @@ void herb_diff_children( const edit_entry_T* remove_entry = &edit_script[remove_indices[remove_index]]; const AST_NODE_T* old_child = (const AST_NODE_T*) hb_array_get(old_children, remove_entry->old_index); + herb_hash_T old_hash = herb_hash_map_get(old_hashes, old_child); for (size_t insert_index = 0; insert_index < insert_count; insert_index++) { diff --git a/src/diff/herb_hash_index_map.c b/src/diff/herb_hash_index_map.c new file mode 100644 index 000000000..7ad33d24c --- /dev/null +++ b/src/diff/herb_hash_index_map.c @@ -0,0 +1,47 @@ +#include "../include/diff/herb_hash_index_map.h" + +#include + +bool herb_hash_index_map_init(herb_hash_index_map_T* map, size_t count, hb_allocator_T* allocator) { + map->capacity = count < 4 ? 8 : count * 3; + size_t alloc_size = map->capacity * sizeof(herb_hash_index_entry_T); + map->entries = (herb_hash_index_entry_T*) hb_allocator_alloc(allocator, alloc_size); + + if (map->entries == NULL) { return false; } + + memset(map->entries, 0, alloc_size); + + return true; +} + +void herb_hash_index_map_insert(herb_hash_index_map_T* map, herb_hash_T key, size_t value) { + size_t slot = (size_t) (key % map->capacity); + + while (map->entries[slot].occupied) { + slot = (slot + 1) % map->capacity; + } + + map->entries[slot].key = key; + map->entries[slot].value = value; + map->entries[slot].occupied = true; +} + +bool herb_hash_index_map_find_unmatched( + const herb_hash_index_map_T* map, + herb_hash_T key, + const bool* matched, + size_t* out_value +) { + size_t slot = (size_t) (key % map->capacity); + + while (map->entries[slot].occupied) { + if (map->entries[slot].key == key && !matched[map->entries[slot].value]) { + *out_value = map->entries[slot].value; + return true; + } + + slot = (slot + 1) % map->capacity; + } + + return false; +} diff --git a/src/include/diff/herb_hash_index_map.h b/src/include/diff/herb_hash_index_map.h new file mode 100644 index 000000000..0dee9e74e --- /dev/null +++ b/src/include/diff/herb_hash_index_map.h @@ -0,0 +1,32 @@ +#ifndef HERB_HASH_INDEX_MAP_H +#define HERB_HASH_INDEX_MAP_H + +#include +#include + +#include "../lib/hb_allocator.h" +#include "herb_hash.h" + +typedef struct { + herb_hash_T key; + size_t value; + bool occupied; +} herb_hash_index_entry_T; + +typedef struct { + herb_hash_index_entry_T* entries; + size_t capacity; +} herb_hash_index_map_T; + +bool herb_hash_index_map_init(herb_hash_index_map_T* map, size_t count, hb_allocator_T* allocator); + +void herb_hash_index_map_insert(herb_hash_index_map_T* map, herb_hash_T key, size_t value); + +bool herb_hash_index_map_find_unmatched( + const herb_hash_index_map_T* map, + herb_hash_T key, + const bool* matched, + size_t* out_value +); + +#endif diff --git a/test/c/test_diff.c b/test/c/test_diff.c index 3a733b7db..69900ed05 100644 --- a/test/c/test_diff.c +++ b/test/c/test_diff.c @@ -308,6 +308,119 @@ TEST(test_diff_empty_documents) hb_allocator_destroy(&allocator); END +TEST(test_diff_multiple_moves) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
  • A
  • B
  • C
  • D
", + "
  • D
  • C
  • B
  • A
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + size_t move_count = 0; + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + if (herb_diff_operation_at(result, index)->type == HERB_DIFF_NODE_MOVED) { move_count++; } + } + ck_assert_uint_ge(move_count, 2); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_move_preserves_indices) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
  • 1
  • 2
", + "
  • 2
  • 1
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + const herb_diff_operation_T* op = herb_diff_operation_at(result, index); + if (op->type == HERB_DIFF_NODE_MOVED) { + ck_assert_uint_ne(op->old_index, op->new_index); + } + } + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_move_among_insertions_and_removals) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
Keep
Remove
", + "
New
Keep
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + bool has_move = false; + bool has_remove = false; + bool has_insert = false; + + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + herb_diff_operation_type_T type = herb_diff_operation_at(result, index)->type; + if (type == HERB_DIFF_NODE_MOVED) { has_move = true; } + if (type == HERB_DIFF_NODE_REMOVED) { has_remove = true; } + if (type == HERB_DIFF_NODE_INSERTED) { has_insert = true; } + } + + ck_assert(has_move); + ck_assert(has_remove); + ck_assert(has_insert); + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_no_move_without_attributes) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
  • A
  • B
  • C
", + "
  • C
  • A
  • B
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + ck_assert_uint_ne(herb_diff_operation_at(result, index)->type, HERB_DIFF_NODE_MOVED); + } + + hb_allocator_destroy(&allocator); +END + +TEST(test_diff_move_with_unchanged_middle) + hb_allocator_T allocator; + hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); + + herb_diff_result_T* result = diff_sources( + "
A
S
B
", + "
B
S
A
", + &allocator + ); + + ck_assert(!herb_diff_trees_identical(result)); + + size_t move_count = 0; + for (size_t index = 0; index < herb_diff_operation_count(result); index++) { + if (herb_diff_operation_at(result, index)->type == HERB_DIFF_NODE_MOVED) { move_count++; } + } + ck_assert_uint_ge(move_count, 1); + + hb_allocator_destroy(&allocator); +END + TEST(test_diff_operation_type_to_string) ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_INSERTED), "node_inserted"); ck_assert_str_eq(herb_diff_operation_type_to_string(HERB_DIFF_NODE_REMOVED), "node_removed"); @@ -344,6 +457,11 @@ TCase *diff_tests(void) { tcase_add_test(diff, test_diff_inserted_has_null_old_node); tcase_add_test(diff, test_diff_removed_has_null_new_node); tcase_add_test(diff, test_diff_empty_documents); + tcase_add_test(diff, test_diff_multiple_moves); + tcase_add_test(diff, test_diff_move_preserves_indices); + tcase_add_test(diff, test_diff_move_among_insertions_and_removals); + tcase_add_test(diff, test_diff_no_move_without_attributes); + tcase_add_test(diff, test_diff_move_with_unchanged_middle); tcase_add_test(diff, test_diff_operation_type_to_string); return diff; diff --git a/test/diff/diff_test.rb b/test/diff/diff_test.rb index 620a082b0..6552507df 100644 --- a/test/diff/diff_test.rb +++ b/test/diff/diff_test.rb @@ -353,5 +353,126 @@ class DiffTest < Minitest::Spec assert_includes types, :node_removed assert_includes types, :node_inserted end + + test "multiple moves with distinguishing attributes" do + result = Herb.diff( + '
  • A
  • B
  • C
  • D
', + '
  • D
  • C
  • B
  • A
' + ) + + refute result.identical? + + move_ops = result.select { |op| op.type == :node_moved } + assert_operator move_ops.size, :>=, 2 + end + + test "move preserves correct indices" do + result = Herb.diff( + '
  • 1
  • 2
', + '
  • 2
  • 1
' + ) + + refute result.identical? + + move_ops = result.select { |op| op.type == :node_moved } + assert_equal 1, move_ops.size + + move = move_ops[0] + refute_equal move.old_index, move.new_index + end + + test "move among other changes" do + result = Herb.diff( + '
Keep
Remove
', + '
New
Keep
' + ) + + refute result.identical? + + types = result.map(&:type) + assert_includes types, :node_moved + assert_operator result.operation_count, :>=, 2 + end + + test "many siblings with single move" do + old_items = (1..10).map { |i| %(
  • Item #{i}
  • ) }.join + new_items = (1..10).map { |i| + index = if i == 1 + 10 + else + (i == 10 ? 1 : i) + end + + %(
  • Item #{index}
  • ) + }.join + + result = Herb.diff("
      #{old_items}
    ", "
      #{new_items}
    ") + + refute result.identical? + + move_ops = result.select { |op| op.type == :node_moved } + assert_operator move_ops.size, :>=, 1 + end + + test "coalesced keep detects tag identity match with attribute changes" do + result = Herb.diff( + '
    Content
    Other
    ', + '
    Other
    Content
    ' + ) + + refute result.identical? + + types = result.map(&:type) + assert_includes types, :node_moved + assert_includes types, :attribute_value_changed + end + + test "wrap detection with multiple candidates" do + result = Herb.diff( + "

    A

    B

    ", + "

    A

    B

    " + ) + + refute result.identical? + + types = result.map(&:type) + assert_includes types, :node_wrapped + end + + test "unwrap detection with multiple candidates" do + result = Herb.diff( + "

    A

    B

    ", + "

    A

    B

    " + ) + + refute result.identical? + + types = result.map(&:type) + assert_includes types, :node_unwrapped + end + + test "move does not match nodes without attributes" do + result = Herb.diff( + "
    • A
    • B
    • C
    ", + "
    • C
    • A
    • B
    " + ) + + refute result.identical? + + types = result.map(&:type) + refute_includes types, :node_moved + end + + test "simultaneous moves and unchanged nodes" do + result = Herb.diff( + '
    A
    S
    B
    ', + '
    B
    S
    A
    ' + ) + + refute result.identical? + + move_ops = result.select { |op| op.type == :node_moved } + assert_operator move_ops.size, :>=, 1 + end end end From d4cdaf82817d021a72a598642f4f8f17813726db Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 15:35:21 +0900 Subject: [PATCH 6/9] Fix test --- test/c/test_diff.c | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/test/c/test_diff.c b/test/c/test_diff.c index 69900ed05..16e851de9 100644 --- a/test/c/test_diff.c +++ b/test/c/test_diff.c @@ -351,7 +351,7 @@ TEST(test_diff_move_preserves_indices) hb_allocator_destroy(&allocator); END -TEST(test_diff_move_among_insertions_and_removals) +TEST(test_diff_move_among_other_changes) hb_allocator_T allocator; hb_allocator_init(&allocator, HB_ALLOCATOR_ARENA); @@ -364,19 +364,14 @@ TEST(test_diff_move_among_insertions_and_removals) ck_assert(!herb_diff_trees_identical(result)); bool has_move = false; - bool has_remove = false; - bool has_insert = false; for (size_t index = 0; index < herb_diff_operation_count(result); index++) { herb_diff_operation_type_T type = herb_diff_operation_at(result, index)->type; if (type == HERB_DIFF_NODE_MOVED) { has_move = true; } - if (type == HERB_DIFF_NODE_REMOVED) { has_remove = true; } - if (type == HERB_DIFF_NODE_INSERTED) { has_insert = true; } } ck_assert(has_move); - ck_assert(has_remove); - ck_assert(has_insert); + ck_assert_uint_ge(herb_diff_operation_count(result), 2); hb_allocator_destroy(&allocator); END @@ -459,7 +454,7 @@ TCase *diff_tests(void) { tcase_add_test(diff, test_diff_empty_documents); tcase_add_test(diff, test_diff_multiple_moves); tcase_add_test(diff, test_diff_move_preserves_indices); - tcase_add_test(diff, test_diff_move_among_insertions_and_removals); + tcase_add_test(diff, test_diff_move_among_other_changes); tcase_add_test(diff, test_diff_no_move_without_attributes); tcase_add_test(diff, test_diff_move_with_unchanged_middle); tcase_add_test(diff, test_diff_operation_type_to_string); From 5fc77663380c0ddf01063b979d91af2a543e4395 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 15:44:39 +0900 Subject: [PATCH 7/9] Update JS import --- javascript/packages/core/src/diff-result.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/packages/core/src/diff-result.ts b/javascript/packages/core/src/diff-result.ts index 11b2455d1..d63f38686 100644 --- a/javascript/packages/core/src/diff-result.ts +++ b/javascript/packages/core/src/diff-result.ts @@ -1,4 +1,4 @@ -import type { SerializedNode } from "./nodes/index.js" +import type { SerializedNode } from "./nodes.js" export type DiffOperationType = | "attribute_added" From a74fc72c6c8500aa685372c1cb35ad9832d554d4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 15:58:12 +0900 Subject: [PATCH 8/9] Add wrapping tests and update playground to better show wrapped operations --- .../src/controllers/playground_controller.js | 116 ++++++++++++++---- test/diff/diff_test.rb | 76 ++++++++++++ 2 files changed, 165 insertions(+), 27 deletions(-) diff --git a/playground/src/controllers/playground_controller.js b/playground/src/controllers/playground_controller.js index c95df6f80..3b4b30b5f 100644 --- a/playground/src/controllers/playground_controller.js +++ b/playground/src/controllers/playground_controller.js @@ -950,16 +950,18 @@ export default class extends Controller { renderOperations(operations) { const typeStyles = { - node_inserted: { css: "inserted", icon: "fa-plus" }, - node_removed: { css: "removed", icon: "fa-minus" }, - node_replaced: { css: "replaced", icon: "fa-right-left" }, - text_changed: { css: "changed", icon: "fa-pen" }, - erb_content_changed: { css: "erb", icon: "fa-code" }, + node_inserted: { css: "inserted", icon: "fa-plus" }, + node_removed: { css: "removed", icon: "fa-minus" }, + node_replaced: { css: "replaced", icon: "fa-right-left" }, + text_changed: { css: "changed", icon: "fa-pen" }, + erb_content_changed: { css: "erb", icon: "fa-code" }, attribute_added: { css: "attribute", icon: "fa-plus" }, - attribute_removed: { css: "removed", icon: "fa-minus" }, + attribute_removed: { css: "removed", icon: "fa-minus" }, attribute_value_changed:{ css: "attribute", icon: "fa-pen" }, - tag_name_changed: { css: "tag", icon: "fa-tag" }, - node_moved: { css: "moved", icon: "fa-arrows-alt" }, + tag_name_changed: { css: "tag", icon: "fa-tag" }, + node_moved: { css: "moved", icon: "fa-arrows-alt" }, + node_wrapped: { css: "wrapped", icon: "fa-compress" }, + node_unwrapped: { css: "unwrapped", icon: "fa-expand" }, } let html = "" @@ -979,34 +981,54 @@ export default class extends Controller { const oldNode = operation.oldNode || operation.old_node const newNode = operation.newNode || operation.new_node - if (oldNode) { - html += `
    - ${oldNode.type}` - - if (oldNode.location) { - html += ` (${oldNode.location.start.line}:${oldNode.location.start.column})` - } + if (operation.type === "node_wrapped" && oldNode && newNode) { + const oldLabel = this.describeNode(oldNode, operation.type) + const newLabel = this.describeNode(newNode, operation.type) + html += `
    ` + html += `${this.escapeHtml(oldLabel)}` + html += ` wrapped in ` + html += `${this.escapeHtml(newLabel)}` + html += `
    ` + } else if (operation.type === "node_unwrapped" && oldNode && newNode) { + const oldLabel = this.describeNode(oldNode, operation.type) + const newLabel = this.describeNode(newNode, operation.type) + + html += `
    ` + html += `${this.escapeHtml(newLabel)}` + html += ` unwrapped from ` + html += `${this.escapeHtml(oldLabel)}` html += `
    ` + } else { + if (oldNode) { + html += `
    - ${oldNode.type}` - const oldValue = this.extractNodeValue(oldNode, operation.type) - if (oldValue !== null) { - html += `
    ${this.escapeHtml(oldValue)}
    ` - } - } + if (oldNode.location) { + html += ` (${oldNode.location.start.line}:${oldNode.location.start.column})` + } - if (newNode) { - html += `
    + ${newNode.type}` + html += `
    ` - if (newNode.location) { - html += ` (${newNode.location.start.line}:${newNode.location.start.column})` + const oldValue = this.extractNodeValue(oldNode, operation.type) + if (oldValue !== null) { + html += `
    ${this.escapeHtml(oldValue)}
    ` + } } - html += `
    ` + if (newNode) { + html += `
    + ${newNode.type}` - const newValue = this.extractNodeValue(newNode, operation.type) + if (newNode.location) { + html += ` (${newNode.location.start.line}:${newNode.location.start.column})` + } + + html += `
    ` - if (newValue !== null) { - html += `
    ${this.escapeHtml(newValue)}
    ` + const newValue = this.extractNodeValue(newNode, operation.type) + + if (newValue !== null) { + html += `
    ${this.escapeHtml(newValue)}
    ` + } } } @@ -1064,6 +1086,46 @@ export default class extends Controller { return null } + describeNode(node, operationType) { + if (!node) return "unknown" + + if (node.type === "AST_HTML_ELEMENT_NODE" || node.type === "AST_HTML_CONDITIONAL_ELEMENT_NODE") { + if (node.tag_name && node.tag_name.value) { + return `<${node.tag_name.value}>` + } + } + + if (node.type === "AST_HTML_TEXT_NODE") { + const text = node.content || "" + const trimmed = text.trim() + + return trimmed.length > 30 ? `"${trimmed.slice(0, 30)}..."` : `"${trimmed}"` + } + + if (node.type === "AST_ERB_CONTENT_NODE" && node.content && node.content.value) { + return `<%= ${node.content.value.trim()} %>` + } + + if (node.type === "AST_ERB_IF_NODE" || node.type === "AST_ERB_UNLESS_NODE") { + const keyword = node.type === "AST_ERB_IF_NODE" ? "if" : "unless" + const condition = node.content && node.content.value ? node.content.value.trim().replace(/^(if|unless)\s+/, "") : "" + + return condition ? `<% ${keyword} ${condition} %>` : `<% ${keyword} %>` + } + + if (node.type && node.type.startsWith("AST_ERB_")) { + const keyword = node.type.replace("AST_ERB_", "").replace("_NODE", "").toLowerCase().replace(/_/g, " ") + const condition = node.content && node.content.value ? node.content.value.trim() : "" + + return condition ? `<% ${condition} %>` : `<% ${keyword} %>` + } + + const value = this.extractNodeValue(node, operationType) + if (value) return value + + return node.type.replace("AST_", "").replace("_NODE", "").toLowerCase().replace(/_/g, " ") + } + escapeHtml(text) { const div = document.createElement("div") div.textContent = text diff --git a/test/diff/diff_test.rb b/test/diff/diff_test.rb index 6552507df..d693b752f 100644 --- a/test/diff/diff_test.rb +++ b/test/diff/diff_test.rb @@ -474,5 +474,81 @@ class DiffTest < Minitest::Spec move_ops = result.select { |op| op.type == :node_moved } assert_operator move_ops.size, :>=, 1 end + + test "wrap in ERB block" do + result = Herb.diff( + "

    Content

    ", + "<% items.each do |item| %>

    Content

    <% end %>" + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_wrapped, result.first.type + assert_kind_of Herb::AST::HTMLElementNode, result.first.old_node + assert_kind_of Herb::AST::ERBBlockNode, result.first.new_node + end + + test "unwrap from ERB block" do + result = Herb.diff( + "<% items.each do |item| %>

    Content

    <% end %>", + "

    Content

    " + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_unwrapped, result.first.type + assert_kind_of Herb::AST::ERBBlockNode, result.first.old_node + assert_kind_of Herb::AST::HTMLElementNode, result.first.new_node + end + + test "wrap in ERB unless" do + result = Herb.diff( + "
    Content
    ", + "<% unless hidden? %>
    Content
    <% end %>" + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_wrapped, result.first.type + assert_kind_of Herb::AST::ERBUnlessNode, result.first.new_node + end + + test "unwrap from ERB unless" do + result = Herb.diff( + "<% unless hidden? %>
    Content
    <% end %>", + "
    Content
    " + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_unwrapped, result.first.type + assert_kind_of Herb::AST::ERBUnlessNode, result.first.old_node + end + + test "wrap text in HTML element" do + result = Herb.diff( + "
    hello
    ", + "
    hello
    " + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_wrapped, result.first.type + assert_kind_of Herb::AST::HTMLTextNode, result.first.old_node + assert_kind_of Herb::AST::HTMLElementNode, result.first.new_node + end + + test "unwrap text from HTML element" do + result = Herb.diff( + "
    hello
    ", + "
    hello
    " + ) + + refute result.identical? + assert_equal 1, result.operation_count + assert_equal :node_unwrapped, result.first.type + assert_kind_of Herb::AST::HTMLElementNode, result.first.old_node + assert_kind_of Herb::AST::HTMLTextNode, result.first.new_node + end end end From ab18c8cf6a09d8e90a4fd79f235813c78d1cc10b Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 19 Apr 2026 16:10:33 +0900 Subject: [PATCH 9/9] Add credits --- src/diff/herb_diff.c | 4 ++++ src/diff/herb_diff_children.c | 5 +++++ src/include/diff/herb_hash.h | 3 +++ templates/src/diff/herb_hash_tree.c.erb | 6 ++++++ 4 files changed, 18 insertions(+) diff --git a/src/diff/herb_diff.c b/src/diff/herb_diff.c index 443719ccb..5749a65f2 100644 --- a/src/diff/herb_diff.c +++ b/src/diff/herb_diff.c @@ -1,3 +1,7 @@ +// Tree diffing algorithm inspired by React's Reconciliation approach: +// comparing trees level-by-level and producing a minimal set of operations. +// https://legacy.reactjs.org/docs/reconciliation.html + #include "../include/diff/herb_diff.h" #include diff --git a/src/diff/herb_diff_children.c b/src/diff/herb_diff_children.c index b97bb19a4..46a78b048 100644 --- a/src/diff/herb_diff_children.c +++ b/src/diff/herb_diff_children.c @@ -4,6 +4,11 @@ #include +// Longest Common Subsequence (LCS) algorithm using dynamic programming. +// Based on the approach described in: +// Hunt, J. W. and Szymanski, T. G. "A Fast Algorithm for Computing +// Longest Common Subsequences" (1977), Communications of the ACM, 20(5). +// https://en.wikipedia.org/wiki/Longest_common_subsequence #define LCS_MAX_SIZE 256 typedef enum { diff --git a/src/include/diff/herb_hash.h b/src/include/diff/herb_hash.h index bb9169001..e8f0bf678 100644 --- a/src/include/diff/herb_hash.h +++ b/src/include/diff/herb_hash.h @@ -8,6 +8,9 @@ typedef uint64_t herb_hash_T; +// FNV-1a 64-bit hash constants +// Fowler-Noll-Vo hash function: http://www.isthe.com/chongo/tech/comp/fnv/ +// Created by Glenn Fowler, Landon Curt Noll, and Kiem-Phong Vo #define HERB_HASH_INIT ((herb_hash_T) 0xcbf29ce484222325ULL) #define HERB_HASH_FNV_PRIME ((herb_hash_T) 0x100000001b3ULL) diff --git a/templates/src/diff/herb_hash_tree.c.erb b/templates/src/diff/herb_hash_tree.c.erb index 8b5f48fab..90d145716 100644 --- a/templates/src/diff/herb_hash_tree.c.erb +++ b/templates/src/diff/herb_hash_tree.c.erb @@ -1,3 +1,9 @@ +// Merkle tree hashing: each node's hash incorporates the hashes of its children, +// enabling O(1) subtree equality checks during tree diffing. +// Based on: Merkle, R. "A Digital Signature Based on a Conventional Encryption +// Function" (1987), Advances in Cryptology - CRYPTO '87. +// https://en.wikipedia.org/wiki/Merkle_tree + #include "../include/diff/herb_diff.h" static herb_hash_T hash_token(herb_hash_T hash, const token_T* token) {