diff --git a/packages/agtree/src/ast-utils/clone.ts b/packages/agtree/src/ast-utils/clone.ts index 58cd5d583c..01ffceb664 100644 --- a/packages/agtree/src/ast-utils/clone.ts +++ b/packages/agtree/src/ast-utils/clone.ts @@ -1,17 +1,65 @@ /** - * @file Custom clone functions for AST nodes, this is probably the most efficient way to clone AST nodes. + * @file Custom clone functions for AST nodes. + * + * Faster than a generic deep-clone library because we know the exact structure + * of each node type and only copy what's needed. * * @todo Maybe move them to parser classes as 'clone' methods. */ import { + type AgentCommentRule, + type AnyCommentRule, + type AnyNetworkRule, + type AnyCosmeticRule, + type AnyRule, + type CommentRule, + type ConfigCommentRule, + type ConfigNode, + type CssInjectionRule, type DomainList, + type ElementHidingRule, + type EmptyRule, + type HintCommentRule, + type HtmlFilteringRule, + type HtmlFilteringRuleBody, + type HostRule, + type InvalidRule, + type JsInjectionRule, + type MetadataCommentRule, type Modifier, type ModifierList, + type NetworkRule, type ParameterList, + type PreProcessorCommentRule, + type ScriptletInjectionRule, + type SelectorList, + type Value, + CommentRuleType, + CosmeticRuleType, + NetworkRuleType, } from '../nodes'; import { isNull } from '../utils/type-guards'; +// Value nodes only contain primitives, spread is enough +function cloneValue(node: Value): Value { + return { ...node }; +} + +// ConfigNode.value is an arbitrary object, structuredClone is the safe option here +function cloneConfigNode(node: ConfigNode): ConfigNode { + return { + ...node, + value: structuredClone(node.value), + }; +} + +// raws only has primitive fields (text, nl), but it's still an object reference +type Raws = NonNullable; +function cloneRaws(raws: Raws | undefined): Raws | undefined { + return raws ? { ...raws } : undefined; +} + /** * Clones a scriptlet rule node. * @@ -21,7 +69,7 @@ import { isNull } from '../utils/type-guards'; */ export function cloneScriptletRuleNode(node: ParameterList): ParameterList { return { - type: node.type, + ...node, children: node.children.map((child) => (isNull(child) ? null : { ...child })), }; } @@ -35,8 +83,7 @@ export function cloneScriptletRuleNode(node: ParameterList): ParameterList { */ export function cloneDomainListNode(node: DomainList): DomainList { return { - type: node.type, - separator: node.separator, + ...node, children: node.children.map((domain) => ({ ...domain })), }; } @@ -50,11 +97,10 @@ export function cloneDomainListNode(node: DomainList): DomainList { */ export function cloneModifierListNode(node: ModifierList): ModifierList { return { - type: node.type, + ...node, children: node.children.map((modifier): Modifier => { const res: Modifier = { - type: modifier.type, - exception: modifier.exception, + ...modifier, name: { ...modifier.name }, }; @@ -66,3 +112,311 @@ export function cloneModifierListNode(node: ModifierList): ModifierList { }), }; } + +// HtmlFilteringRuleBody has a nested SelectorList, so we go one level deeper +function cloneHtmlFilteringRuleBody(node: HtmlFilteringRuleBody): HtmlFilteringRuleBody { + const selectorList: SelectorList = { + ...node.selectorList, + children: node.selectorList.children.map((complexSelector) => ({ + ...complexSelector, + children: complexSelector.children.map((child) => ({ ...child })), + })), + }; + + return { ...node, selectorList }; +} + +// --- comment rules --- + +function cloneCommentRule(node: CommentRule): CommentRule { + return { + ...node, + raws: cloneRaws(node.raws), + marker: cloneValue(node.marker), + text: cloneValue(node.text), + }; +} + +function cloneMetadataCommentRule(node: MetadataCommentRule): MetadataCommentRule { + return { + ...node, + raws: cloneRaws(node.raws), + marker: cloneValue(node.marker), + header: cloneValue(node.header), + value: cloneValue(node.value), + }; +} + +function cloneConfigCommentRule(node: ConfigCommentRule): ConfigCommentRule { + const result: ConfigCommentRule = { + ...node, + raws: cloneRaws(node.raws), + marker: cloneValue(node.marker), + command: cloneValue(node.command), + }; + + if (node.params) { + result.params = node.params.type === 'ConfigNode' + ? cloneConfigNode(node.params) + : cloneScriptletRuleNode(node.params); + } + + if (node.comment) { + result.comment = cloneValue(node.comment); + } + + return result; +} + +function clonePreProcessorCommentRule(node: PreProcessorCommentRule): PreProcessorCommentRule { + const result: PreProcessorCommentRule = { + ...node, + raws: cloneRaws(node.raws), + name: cloneValue(node.name), + }; + + if (node.params) { + if (node.params.type === 'Value') { + result.params = cloneValue(node.params); + } else if (node.params.type === 'ParameterList') { + result.params = cloneScriptletRuleNode(node.params); + } else { + // expression nodes are rare and have no dedicated clone path yet + result.params = structuredClone(node.params); + } + } + + return result; +} + +function cloneAgentCommentRule(node: AgentCommentRule): AgentCommentRule { + return { + ...node, + raws: cloneRaws(node.raws), + children: node.children.map((agent) => ({ + ...agent, + adblock: cloneValue(agent.adblock), + ...(agent.version ? { version: cloneValue(agent.version) } : {}), + })), + }; +} + +function cloneHintCommentRule(node: HintCommentRule): HintCommentRule { + return { + ...node, + raws: cloneRaws(node.raws), + children: node.children.map((hint) => ({ + ...hint, + name: cloneValue(hint.name), + ...(hint.params ? { params: cloneScriptletRuleNode(hint.params) } : {}), + })), + }; +} + +// --- network rules --- + +function cloneNetworkRule(node: NetworkRule): NetworkRule { + const result: NetworkRule = { + ...node, + raws: cloneRaws(node.raws), + pattern: cloneValue(node.pattern), + }; + + if (node.modifiers) { + result.modifiers = cloneModifierListNode(node.modifiers); + } + + return result; +} + +function cloneHostRule(node: HostRule): HostRule { + const result: HostRule = { + ...node, + raws: cloneRaws(node.raws), + ip: cloneValue(node.ip), + hostnames: { + ...node.hostnames, + children: node.hostnames.children.map((h) => ({ ...h })), + }, + }; + + if (node.comment) { + result.comment = cloneValue(node.comment); + } + + return result; +} + +// --- cosmetic rules --- + +function cloneElementHidingRule(node: ElementHidingRule): ElementHidingRule { + return { + ...node, + raws: cloneRaws(node.raws), + domains: cloneDomainListNode(node.domains), + separator: cloneValue(node.separator), + body: { ...node.body, selectorList: cloneValue(node.body.selectorList) }, + ...(node.modifiers ? { modifiers: cloneModifierListNode(node.modifiers) } : {}), + }; +} + +function cloneCssInjectionRule(node: CssInjectionRule): CssInjectionRule { + const body = { + ...node.body, + selectorList: cloneValue(node.body.selectorList), + ...(node.body.mediaQueryList ? { mediaQueryList: cloneValue(node.body.mediaQueryList) } : {}), + ...(node.body.declarationList ? { declarationList: cloneValue(node.body.declarationList) } : {}), + }; + + return { + ...node, + raws: cloneRaws(node.raws), + domains: cloneDomainListNode(node.domains), + separator: cloneValue(node.separator), + body, + ...(node.modifiers ? { modifiers: cloneModifierListNode(node.modifiers) } : {}), + }; +} + +function cloneScriptletInjectionRule(node: ScriptletInjectionRule): ScriptletInjectionRule { + return { + ...node, + raws: cloneRaws(node.raws), + domains: cloneDomainListNode(node.domains), + separator: cloneValue(node.separator), + body: { + ...node.body, + children: node.body.children.map((paramList) => cloneScriptletRuleNode(paramList)), + }, + ...(node.modifiers ? { modifiers: cloneModifierListNode(node.modifiers) } : {}), + }; +} + +function cloneHtmlFilteringRule(node: HtmlFilteringRule): HtmlFilteringRule { + const body = node.body.type === 'HtmlFilteringRuleBody' + ? cloneHtmlFilteringRuleBody(node.body) + : cloneValue(node.body as Value); + + return { + ...node, + raws: cloneRaws(node.raws), + domains: cloneDomainListNode(node.domains), + separator: cloneValue(node.separator), + body, + ...(node.modifiers ? { modifiers: cloneModifierListNode(node.modifiers) } : {}), + }; +} + +function cloneJsInjectionRule(node: JsInjectionRule): JsInjectionRule { + return { + ...node, + raws: cloneRaws(node.raws), + domains: cloneDomainListNode(node.domains), + separator: cloneValue(node.separator), + body: cloneValue(node.body), + ...(node.modifiers ? { modifiers: cloneModifierListNode(node.modifiers) } : {}), + }; +} + +// --- public API --- + +/** + * Clones any comment rule node. + * + * @param node Node to clone. + * + * @returns Cloned node. + */ +export function cloneAnyCommentRule(node: T): T { + switch (node.type) { + case CommentRuleType.CommentRule: + return cloneCommentRule(node) as T; + case CommentRuleType.MetadataCommentRule: + return cloneMetadataCommentRule(node) as T; + case CommentRuleType.ConfigCommentRule: + return cloneConfigCommentRule(node) as T; + case CommentRuleType.PreProcessorCommentRule: + return clonePreProcessorCommentRule(node) as T; + case CommentRuleType.AgentCommentRule: + return cloneAgentCommentRule(node) as T; + case CommentRuleType.HintCommentRule: + return cloneHintCommentRule(node) as T; + default: + throw new Error(`Unknown comment rule type: ${(node as AnyCommentRule).type}`); + } +} + +/** + * Clones any network rule node. + * + * @param node Node to clone. + * + * @returns Cloned node. + */ +export function cloneAnyNetworkRule(node: T): T { + switch (node.type) { + case NetworkRuleType.NetworkRule: + return cloneNetworkRule(node) as T; + case NetworkRuleType.HostRule: + return cloneHostRule(node) as T; + default: + throw new Error(`Unknown network rule type: ${(node as AnyNetworkRule).type}`); + } +} + +/** + * Clones any cosmetic rule node. + * + * @param node Node to clone. + * + * @returns Cloned node. + */ +export function cloneAnyCosmeticRule(node: T): T { + switch (node.type) { + case CosmeticRuleType.ElementHidingRule: + return cloneElementHidingRule(node) as T; + case CosmeticRuleType.CssInjectionRule: + return cloneCssInjectionRule(node) as T; + case CosmeticRuleType.ScriptletInjectionRule: + return cloneScriptletInjectionRule(node) as T; + case CosmeticRuleType.HtmlFilteringRule: + return cloneHtmlFilteringRule(node) as T; + case CosmeticRuleType.JsInjectionRule: + return cloneJsInjectionRule(node) as T; + default: + throw new Error(`Unknown cosmetic rule type: ${(node as AnyCosmeticRule).type}`); + } +} + +/** + * Clones any AST rule node. + * + * Faster than `clone-deep` because we know the exact structure of each node type + * and don't need to traverse arbitrary objects. + * + * @param node Node to clone. + * + * @returns Cloned node. + * + * @example + * ```ts + * const cloned = cloneAnyRule(RuleParser.parse('example.com##.ad')); + * cloned.domains.children[0].value = 'other.com'; // original is not affected + * ``` + */ +export function cloneAnyRule(node: AnyRule): AnyRule { + switch (node.category) { + case 'Empty': + return { ...node }; + case 'Invalid': + return { ...node, error: { ...node.error } } as InvalidRule; + case 'Comment': + return cloneAnyCommentRule(node); + case 'Network': + return cloneAnyNetworkRule(node); + case 'Cosmetic': + return cloneAnyCosmeticRule(node); + default: + throw new Error(`Unknown rule category: ${(node as AnyRule).category}`); + } +} diff --git a/packages/agtree/src/converter/comment/index.ts b/packages/agtree/src/converter/comment/index.ts index a8641ae20c..b7bc566f2c 100644 --- a/packages/agtree/src/converter/comment/index.ts +++ b/packages/agtree/src/converter/comment/index.ts @@ -3,7 +3,7 @@ */ import { type AnyCommentRule, CommentMarker, CommentRuleType } from '../../nodes'; -import { clone } from '../../utils/clone'; +import { cloneAnyCommentRule } from '../../ast-utils/clone'; import { SPACE } from '../../utils/constants'; import { createNodeConversionResult, type NodeConversionResult } from '../base-interfaces/conversion-result'; import { RuleConverterBase } from '../base-interfaces/rule-converter-base'; @@ -33,8 +33,7 @@ export class CommentRuleConverter extends RuleConverterBase { // Check if the rule needs to be converted if (rule.type === CommentRuleType.CommentRule && rule.marker.value === CommentMarker.Hashmark) { // Add a ! to the beginning of the comment - // TODO: Replace with custom clone method - const ruleClone = clone(rule); + const ruleClone = cloneAnyCommentRule(rule); ruleClone.marker.value = CommentMarker.Regular; diff --git a/packages/agtree/src/converter/cosmetic/css.ts b/packages/agtree/src/converter/cosmetic/css.ts index 8401c6fcf1..9e52871484 100644 --- a/packages/agtree/src/converter/cosmetic/css.ts +++ b/packages/agtree/src/converter/cosmetic/css.ts @@ -5,7 +5,7 @@ import { CosmeticRuleSeparator, type CssInjectionRule } from '../../nodes'; import { CssTokenStream } from '../../parser/css/css-token-stream'; import { AdblockSyntax } from '../../utils/adblockers'; -import { clone } from '../../utils/clone'; +import { cloneAnyCosmeticRule } from '../../ast-utils/clone'; import { createNodeConversionResult, type NodeConversionResult } from '../base-interfaces/conversion-result'; import { RuleConverterBase } from '../base-interfaces/rule-converter-base'; import { CssSelectorConverter } from '../css'; @@ -54,8 +54,7 @@ export class CssInjectionRuleConverter extends RuleConverterBase { || separator !== convertedSeparator || convertedSelectorList.isConverted ) { - // TODO: Replace with custom clone method - const ruleClone = clone(rule); + const ruleClone = cloneAnyCosmeticRule(rule); ruleClone.syntax = AdblockSyntax.Adg; ruleClone.separator.value = convertedSeparator; diff --git a/packages/agtree/src/converter/cosmetic/element-hiding.ts b/packages/agtree/src/converter/cosmetic/element-hiding.ts index 78c071c2d3..661b591f36 100644 --- a/packages/agtree/src/converter/cosmetic/element-hiding.ts +++ b/packages/agtree/src/converter/cosmetic/element-hiding.ts @@ -5,7 +5,7 @@ import { CosmeticRuleSeparator, type ElementHidingRule } from '../../nodes'; import { CssTokenStream } from '../../parser/css/css-token-stream'; import { AdblockSyntax } from '../../utils/adblockers'; -import { clone } from '../../utils/clone'; +import { cloneAnyCosmeticRule } from '../../ast-utils/clone'; import { createNodeConversionResult, type NodeConversionResult } from '../base-interfaces/conversion-result'; import { RuleConverterBase } from '../base-interfaces/rule-converter-base'; import { CssSelectorConverter } from '../css'; @@ -48,8 +48,7 @@ export class ElementHidingRuleConverter extends RuleConverterBase { || separator !== convertedSeparator || convertedSelectorList.isConverted ) { - // TODO: Replace with custom clone method - const ruleClone = clone(rule); + const ruleClone = cloneAnyCosmeticRule(rule); ruleClone.syntax = AdblockSyntax.Adg; ruleClone.separator.value = convertedSeparator; diff --git a/packages/agtree/test/ast-utils/clone.test.ts b/packages/agtree/test/ast-utils/clone.test.ts new file mode 100644 index 0000000000..8ac8b08915 --- /dev/null +++ b/packages/agtree/test/ast-utils/clone.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from 'vitest'; + +import { + cloneAnyCommentRule, + cloneAnyNetworkRule, + cloneAnyCosmeticRule, + cloneAnyRule, + cloneDomainListNode, + cloneModifierListNode, + cloneScriptletRuleNode, +} from '../../src/ast-utils/clone'; +import { HostRuleParser } from '../../src/parser/network/host-rule-parser'; +import { RuleParser } from '../../src/parser/rule-parser'; + +// Helpers + +/** + * Parses a raw rule string and returns the AST node. + * Throws if parsing results in an InvalidRule. + */ +function parse(raw: string) { + const node = RuleParser.parse(raw); + + if (node.type === 'InvalidRule') { + throw new Error(`Failed to parse rule: ${raw}`); + } + + return node; +} + +// Sub-node cloners + +describe('cloneModifierListNode', () => { + it('should return a deep copy, not the same reference', () => { + const node = parse('||example.com^$script,domain=example.org'); + + if (node.type !== 'NetworkRule' || !node.modifiers) { + throw new Error('Expected a NetworkRule with modifiers'); + } + + const { modifiers } = node; + const cloned = cloneModifierListNode(modifiers); + + expect(cloned).toEqual(modifiers); + expect(cloned).not.toBe(modifiers); + expect(cloned.children[0]).not.toBe(modifiers.children[0]); + expect(cloned.children[0].name).not.toBe(modifiers.children[0].name); + }); + + it('should clone modifier values independently', () => { + const node = parse('||example.com^$domain=example.org'); + + if (node.type !== 'NetworkRule' || !node.modifiers) { + throw new Error('Expected a NetworkRule with modifiers'); + } + + const { modifiers } = node; + const cloned = cloneModifierListNode(modifiers); + + // Mutate the clone — original must stay unchanged + cloned.children[0].name.value = 'mutated'; + + expect(modifiers.children[0].name.value).toBe('domain'); + }); +}); + +describe('cloneDomainListNode', () => { + it('should return a deep copy of the domain list', () => { + const node = parse('example.com,~example.org##.ad'); + + if (node.type !== 'ElementHidingRule') { + throw new Error('Expected ElementHidingRule'); + } + + const { domains } = node; + const cloned = cloneDomainListNode(domains); + + expect(cloned).toEqual(domains); + expect(cloned).not.toBe(domains); + expect(cloned.children[0]).not.toBe(domains.children[0]); + }); + + it('should clone domains independently', () => { + const node = parse('example.com##.ad'); + + if (node.type !== 'ElementHidingRule') { + throw new Error('Expected ElementHidingRule'); + } + + const cloned = cloneDomainListNode(node.domains); + cloned.children[0].value = 'mutated.com'; + + expect(node.domains.children[0].value).toBe('example.com'); + }); +}); + +describe('cloneScriptletRuleNode', () => { + it('should clone a ParameterList with null entries', () => { + const node = parse("example.com#%#//scriptlet('log', 'test')"); + + if (node.type !== 'ScriptletInjectionRule') { + throw new Error('Expected ScriptletInjectionRule'); + } + + const paramList = node.body.children[0]; + const cloned = cloneScriptletRuleNode(paramList); + + expect(cloned).toEqual(paramList); + expect(cloned).not.toBe(paramList); + expect(cloned.children[0]).not.toBe(paramList.children[0]); + }); +}); + +// cloneAnyRule — comment rules + +describe('cloneAnyRule — comment rules', () => { + it('should clone a CommentRule', () => { + const node = parse('! This is a comment'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'CommentRule' || node.type !== 'CommentRule') { + throw new Error('Expected CommentRule'); + } + + expect(cloned.marker).not.toBe(node.marker); + expect(cloned.text).not.toBe(node.text); + + // Mutation isolation + cloned.text.value = 'mutated'; + expect(node.text.value).toBe(' This is a comment'); + }); + + it('should clone a MetadataCommentRule', () => { + const node = parse('! Title: My Filter List'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'MetadataCommentRule' || node.type !== 'MetadataCommentRule') { + throw new Error('Expected MetadataCommentRule'); + } + + expect(cloned.header).not.toBe(node.header); + expect(cloned.value).not.toBe(node.value); + }); + + it('should clone a PreProcessorCommentRule', () => { + const node = parse('!#if (adguard)'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + }); + + it('should clone an AgentCommentRule', () => { + const node = parse('[Adblock Plus 2.0]'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'AgentCommentRule' || node.type !== 'AgentCommentRule') { + throw new Error('Expected AgentCommentRule'); + } + + expect(cloned.children[0]).not.toBe(node.children[0]); + expect(cloned.children[0].adblock).not.toBe(node.children[0].adblock); + }); + + it('should clone a HintCommentRule', () => { + const node = parse('!+ PLATFORM(windows, mac)'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'HintCommentRule' || node.type !== 'HintCommentRule') { + throw new Error('Expected HintCommentRule'); + } + + expect(cloned.children[0]).not.toBe(node.children[0]); + expect(cloned.children[0].name).not.toBe(node.children[0].name); + }); +}); + +// cloneAnyRule — network rules + +describe('cloneAnyRule — network rules', () => { + it('should clone a basic NetworkRule', () => { + const node = parse('||example.com^'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'NetworkRule' || node.type !== 'NetworkRule') { + throw new Error('Expected NetworkRule'); + } + + expect(cloned.pattern).not.toBe(node.pattern); + + // Mutation isolation + cloned.pattern.value = 'mutated'; + expect(node.pattern.value).toBe('||example.com^'); + }); + + it('should clone a NetworkRule with modifiers', () => { + const node = parse('||example.com^$script,domain=example.org'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'NetworkRule' || node.type !== 'NetworkRule') { + throw new Error('Expected NetworkRule'); + } + + expect(cloned.modifiers).not.toBe(node.modifiers); + expect(cloned.modifiers!.children[0]).not.toBe(node.modifiers!.children[0]); + }); + + it('should clone a HostRule', () => { + // HostRuleParser parses hosts-file format directly + const node = HostRuleParser.parse('127.0.0.1 example.com example.org'); + const cloned = cloneAnyNetworkRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + expect(cloned.type).toBe('HostRule'); + + if (cloned.type !== 'HostRule' || node.type !== 'HostRule') { + throw new Error('Expected HostRule'); + } + + expect(cloned.ip).not.toBe(node.ip); + expect(cloned.hostnames).not.toBe(node.hostnames); + expect(cloned.hostnames.children[0]).not.toBe(node.hostnames.children[0]); + }); +}); + +// cloneAnyRule — cosmetic rules + +describe('cloneAnyRule — cosmetic rules', () => { + it('should clone an ElementHidingRule', () => { + const node = parse('example.com,~example.org##.ad-banner'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'ElementHidingRule' || node.type !== 'ElementHidingRule') { + throw new Error('Expected ElementHidingRule'); + } + + expect(cloned.domains).not.toBe(node.domains); + expect(cloned.domains.children[0]).not.toBe(node.domains.children[0]); + expect(cloned.body).not.toBe(node.body); + + // Mutation isolation + cloned.domains.children[0].value = 'mutated.com'; + expect(node.domains.children[0].value).toBe('example.com'); + }); + + it('should clone a CssInjectionRule', () => { + const node = parse('example.com#$#body { padding-top: 0 !important; }'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'CssInjectionRule' || node.type !== 'CssInjectionRule') { + throw new Error('Expected CssInjectionRule'); + } + + expect(cloned.body).not.toBe(node.body); + expect(cloned.body.selectorList).not.toBe(node.body.selectorList); + }); + + it('should clone a ScriptletInjectionRule', () => { + const node = parse("example.com#%#//scriptlet('log', 'arg1')"); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'ScriptletInjectionRule' || node.type !== 'ScriptletInjectionRule') { + throw new Error('Expected ScriptletInjectionRule'); + } + + expect(cloned.body).not.toBe(node.body); + expect(cloned.body.children[0]).not.toBe(node.body.children[0]); + }); + + it('should clone a JsInjectionRule', () => { + const node = parse('example.com#%#let a = 2;'); + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + + if (cloned.type !== 'JsInjectionRule' || node.type !== 'JsInjectionRule') { + throw new Error('Expected JsInjectionRule'); + } + + expect(cloned.body).not.toBe(node.body); + }); + + it('should clone an EmptyRule', () => { + const node = parse(''); + + expect(node.type).toBe('EmptyRule'); + + const cloned = cloneAnyRule(node); + + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + }); +}); + +// cloneAnyCommentRule / cloneAnyNetworkRule / cloneAnyCosmeticRule + +describe('cloneAnyCommentRule', () => { + it('should produce a deep independent copy', () => { + const node = parse('! Version: 2.0'); + + if (node.type !== 'MetadataCommentRule') { + throw new Error('Expected MetadataCommentRule'); + } + + const cloned = cloneAnyCommentRule(node); + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + }); +}); + +describe('cloneAnyNetworkRule', () => { + it('should produce a deep independent copy', () => { + const node = parse('@@||example.com^$important'); + + if (node.type !== 'NetworkRule') { + throw new Error('Expected NetworkRule'); + } + + const cloned = cloneAnyNetworkRule(node); + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + }); +}); + +describe('cloneAnyCosmeticRule', () => { + it('should produce a deep independent copy', () => { + const node = parse('example.com#@#.ad'); + + if (node.type !== 'ElementHidingRule') { + throw new Error('Expected ElementHidingRule'); + } + + const cloned = cloneAnyCosmeticRule(node); + expect(cloned).toEqual(node); + expect(cloned).not.toBe(node); + }); +});