Mutare.Transform.NodeRange (mutare v0.4.1)

Copy Markdown View Source

Sourceror.get_range/1, corrected where its answer would corrupt a survivor's reported location or diff: surrounding parentheses, a leading parenthesized callee, a bitstring's over-counted end, and an escaped-delimiter under-count.

Surrounding parentheses. Sourceror extends a parenthesized node's range over the parentheses written around it. The text a Mutare.Site renders for that node never includes them — (a + b) * c records a + b → a - b — so patched over the wider span the report would show a - b * c, a different program from the mutant that ran, and &(&1 > 2) would become the unparseable &&1 >= 2. The parentheses belong to the context the node sits in, so get/1 ranges the node without them, however many layers or however spaced.

A leading call on a parenthesized callee. Sourceror ranges (fn x -> x end).(1) and (a).b from inside the callee's parentheses, and with them every node that begins with one. A whole-expression replacement over that span leaves the ( behind — (fn … end).(1) |> f() → nil patched to the unparseable (nil. get/1 starts the range at the earliest start down the node's left spine — and, for the mirror image (0 == (if x do … end), ranged to inside the )), ends it at the latest end down the right spine.

The escaped-delimiter under-count in sigils and interpolated strings. Sourceror computes a sigil's end column from the stored content length (range.ex get_end_pos_for_interpolation_segments/3, String.length of the <<>> segments). The tokenizer keeps a sigil's content raw — \n/\\/\t stay two-character sequences — except it collapses an escaped closing delimiter (\/ → / in ~r/…/, \} → } in ~r{…}), so the stored content is one byte shorter per such escape and the range falls short by that many columns. A textual patch over the short range leaves the sigil's tail in place — and when the mutation only drops a trailing flag (~r/…/u → ~r/…/, RegexLiteral), the patch lands exactly on the dropped u and the diff shows no change at all (a survivor with an empty diff). get/1 adds back one column per collapsed closing delimiter. (Escaped delimiters before the last interpolation are already accounted for — their absolute closing position is baked into the segment metadata — so only the trailing binary segments are counted; an opening delimiter escape \{ keeps its backslash and never collapses, so only the closing char is counted.)

The same collapse hits the interpolated string family — "a\"#{x}\"b" and its charlist / quoted-atom cousins also range from segment lengths, and the tokenizer collapses \" → " (or \' → ') there too, so an escaped quote after the last interpolation shortens the range and the patch leaves the original closing quote behind (toast("…\"#{x}\".") mutated to "" renders as toast(""")). get/1 applies the same trailing-segment count to those three container shapes. Non-interpolated strings are immune (their raw content keeps the backslash), and heredocs never escape their fence, so only the quote-delimited (non-heredoc), single-line, interpolated forms are corrected.

Only Mutare.Report reads the range, so the quirk is invisible at runtime: the metamutant is built from the AST, never the range. It corrupts only a surviving mutant's rendered location — the human diff, and the endColumn the machine reporters emit (Mutare.Report.Json / Mutare.Report.Sarif). See NOTES "Sourceror range".

Summary

Functions

Like Sourceror.get_range/1, correcting the sigil/interpolated-string under-count.

Functions

get(node)

@spec get(Macro.t()) :: Sourceror.Range.t() | nil

Like Sourceror.get_range/1, correcting the sigil/interpolated-string under-count.