Skip to content

Commit c623952

Browse files
committed
Normalize boolean operators and expand ~w sigils
Canonicalize &&/||/! to and/or/not so stylistic choice between short-circuit and keyword operators doesn't prevent clone matching. Expand static ~w(...)a/s/c sigils to their literal list form so ~w(foo bar)a matches [:foo, :bar].
1 parent 2cdf0c4 commit c623952

2 files changed

Lines changed: 104 additions & 2 deletions

File tree

lib/ex_dna/ast/normalizer.ex

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ defmodule ExDNA.AST.Normalizer do
1313
placeholders (`:$0`, `:$1`, …) based on first-occurrence order.
1414
3. **Literal abstraction** (optional) — replaces concrete literals with
1515
type-tagged placeholders to detect Type-II clones.
16-
4. **Map/struct field sorting** (abstract mode) — sorts key-value pairs
16+
4. **Boolean operator canonicalization** — `&&`/`||`/`!` are rewritten to
17+
`and`/`or`/`not` so stylistic choice between short-circuit and keyword
18+
operators doesn’t affect comparison.
19+
5. **Sigil expansion** — `~w(foo bar)a` is expanded to `[:foo, :bar]` (and
20+
likewise for string modifiers) so sigil word-lists match their literal
21+
equivalents.
22+
6. **Map/struct field sorting** (abstract mode) — sorts key-value pairs
1723
so that `%{b: 1, a: 2}` and `%{a: 2, b: 1}` produce the same hash.
18-
5. **Guard abstraction** (abstract mode) — in `when` clauses, replaces
24+
7. **Guard abstraction** (abstract mode) — in `when` clauses, replaces
1925
all function/macro call names with a `:__guard__` placeholder so that
2026
`when is_binary(x)` and `when is_atom(x)` produce the same hash.
2127
Covers all Kernel guards, Erlang BIF guards, `defguard` macros,
@@ -44,6 +50,8 @@ defmodule ExDNA.AST.Normalizer do
4450

4551
ast
4652
|> strip_metadata()
53+
|> canonicalize_operators()
54+
|> expand_sigils()
4755
|> maybe_normalize_pipes(normalize_pipes)
4856
|> normalize_variables()
4957
|> maybe_abstract_literals(literal_mode)
@@ -94,6 +102,39 @@ defmodule ExDNA.AST.Normalizer do
94102

95103
defp rename_var(node, env), do: {node, env}
96104

105+
@bool_canon %{:&& => :and, :|| => :or, :! => :not}
106+
107+
defp canonicalize_operators(ast) do
108+
Macro.prewalk(ast, fn
109+
{op, meta, args} when is_map_key(@bool_canon, op) ->
110+
{@bool_canon[op], meta, args}
111+
112+
other ->
113+
other
114+
end)
115+
end
116+
117+
defp expand_sigils(ast) do
118+
Macro.prewalk(ast, fn
119+
{:sigil_w, _meta, [{:<<>>, _, [content]}, modifier]}
120+
when is_binary(content) ->
121+
expand_sigil_w(content, modifier)
122+
123+
other ->
124+
other
125+
end)
126+
end
127+
128+
defp expand_sigil_w(content, modifier) do
129+
words = String.split(content)
130+
131+
case modifier do
132+
~c"a" -> Enum.map(words, &String.to_atom/1)
133+
~c"c" -> Enum.map(words, &String.to_charlist/1)
134+
_ -> words
135+
end
136+
end
137+
97138
defp maybe_normalize_pipes(ast, false), do: ast
98139
defp maybe_normalize_pipes(ast, true), do: PipeNormalizer.normalize(ast)
99140

test/ex_dna/ast/normalizer_test.exs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,67 @@ defmodule ExDNA.AST.NormalizerTest do
5050
end
5151
end
5252

53+
describe "boolean operator canonicalization" do
54+
test "&& and 'and' produce identical normalized AST" do
55+
ast1 = Code.string_to_quoted!("a && b")
56+
ast2 = Code.string_to_quoted!("a and b")
57+
58+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
59+
end
60+
61+
test "|| and 'or' produce identical normalized AST" do
62+
ast1 = Code.string_to_quoted!("a || b")
63+
ast2 = Code.string_to_quoted!("a or b")
64+
65+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
66+
end
67+
68+
test "! and 'not' produce identical normalized AST" do
69+
ast1 = Code.string_to_quoted!("!a")
70+
ast2 = Code.string_to_quoted!("not a")
71+
72+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
73+
end
74+
75+
test "nested boolean operators are canonicalized" do
76+
ast1 = Code.string_to_quoted!("a && b || !c")
77+
ast2 = Code.string_to_quoted!("a and b or not c")
78+
79+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
80+
end
81+
end
82+
83+
describe "sigil expansion" do
84+
test "~w()a matches atom list" do
85+
ast1 = Code.string_to_quoted!("~w(foo bar baz)a")
86+
ast2 = Code.string_to_quoted!("[:foo, :bar, :baz]")
87+
88+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
89+
end
90+
91+
test "~w()s matches string list" do
92+
ast1 = Code.string_to_quoted!("~w(foo bar)s")
93+
ast2 = Code.string_to_quoted!(~s(["foo", "bar"]))
94+
95+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
96+
end
97+
98+
test "~w() without modifier matches string list" do
99+
ast1 = Code.string_to_quoted!("~w(hello world)")
100+
ast2 = Code.string_to_quoted!(~s(["hello", "world"]))
101+
102+
assert Normalizer.normalize(ast1) == Normalizer.normalize(ast2)
103+
end
104+
105+
test "~w with interpolation is not expanded" do
106+
ast = Code.string_to_quoted!(~S"~w(foo #{x} bar)a")
107+
norm = Normalizer.normalize(ast)
108+
109+
{form, _, _} = norm
110+
assert form == :sigil_w
111+
end
112+
end
113+
53114
describe "normalize/2 with literal_mode: :keep" do
54115
test "identical code produces identical normalized AST" do
55116
code = "fn(a, b) -> a + b end"

0 commit comments

Comments
 (0)