From 14a3ddf6a4d0aeb4398368ddcfcb713110e16787 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Sat, 13 Jun 2026 13:22:16 -0400 Subject: [PATCH 1/2] linenumbers --- ext/TensorOperationsBumperExt.jl | 16 ++++----- src/indexnotation/contractiontrees.jl | 2 +- src/indexnotation/parser.jl | 3 +- src/indexnotation/postprocessors.jl | 37 ++++++++++++++++++++ test/butensor.jl | 17 +++++++++ test/macro_kwargs.jl | 50 +++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 11 deletions(-) diff --git a/ext/TensorOperationsBumperExt.jl b/ext/TensorOperationsBumperExt.jl index 12791765..b6713666 100644 --- a/ext/TensorOperationsBumperExt.jl +++ b/ext/TensorOperationsBumperExt.jl @@ -27,16 +27,14 @@ function TensorOperations._butensor(src, ex...) buf_sym = gensym("buffer") # TODO: there is no check for doubled tensor kwargs - newex = quote - $buf_sym = $(Expr(:call, GlobalRef(Bumper, :default_buffer))) - $( - Expr( - :macrocall, GlobalRef(TensorOperations, Symbol("@tensor")), - src, :(allocator = $buf_sym), ex... - ) + return Expr( + :block, + Expr(:(=), buf_sym, Expr(:call, GlobalRef(Bumper, :default_buffer))), + Expr( + :macrocall, GlobalRef(TensorOperations, Symbol("@tensor")), + src, :(allocator = $buf_sym), ex... ) - end - return Base.remove_linenums!(newex) + ) end end diff --git a/src/indexnotation/contractiontrees.jl b/src/indexnotation/contractiontrees.jl index 7f442f4a..3f203041 100644 --- a/src/indexnotation/contractiontrees.jl +++ b/src/indexnotation/contractiontrees.jl @@ -161,7 +161,7 @@ function insertcontractiontrees!( end ) end - push!(postexprs, removelinenumbernode(costcompareex)) + push!(postexprs, removeinternallinenumbernodes(costcompareex)) return treeex end diff --git a/src/indexnotation/parser.jl b/src/indexnotation/parser.jl index 6afc0729..646729a4 100644 --- a/src/indexnotation/parser.jl +++ b/src/indexnotation/parser.jl @@ -13,7 +13,7 @@ mutable struct TensorParser contractiontreebuilder = defaulttreebuilder contractiontreesorter = defaulttreesorter contractioncostcheck = nothing - postprocessors = [_flatten, removelinenumbernode, addtensoroperations] + postprocessors = [_flatten, addtensoroperations] return new( preprocessors, contractiontreebuilder, contractiontreesorter, contractioncostcheck, @@ -35,6 +35,7 @@ function (parser::TensorParser)(ex::Expr) for p in parser.postprocessors ex = p(ex)::Expr end + ex = removeinternallinenumbernodes(ex)::Expr return ex end diff --git a/src/indexnotation/postprocessors.jl b/src/indexnotation/postprocessors.jl index 25b4297a..8909dd58 100644 --- a/src/indexnotation/postprocessors.jl +++ b/src/indexnotation/postprocessors.jl @@ -30,10 +30,47 @@ function _flatten(ex) end end +# package source directory (with trailing separator), used to recognize `LineNumberNode`s that +# point into the parser's own `quote` blocks rather than into user code. +const _PARSER_SRCDIR = joinpath(dirname(@__DIR__), "") + +_isinternallinenumber(@nospecialize(x)) = + x isa LineNumberNode && startswith(String(x.file), _PARSER_SRCDIR) + +""" + removeinternallinenumbernodes(ex) + +Remove all `LineNumberNode`s that point into the TensorOperations source tree, i.e. the ones +introduced by the parser's own `quote` blocks. `LineNumberNode`s originating from user code are +kept, so that the generated code remains attributable to the user's source lines (e.g. for code +coverage). +""" +function removeinternallinenumbernodes(ex) + if isexpr(ex, :block) + # within a block, `LineNumberNode`s are statement markers: drop the internal ones + args = Any[ + removeinternallinenumbernodes(e) for e in ex.args + if !_isinternallinenumber(e) + ] + return Expr(:block, args...) + elseif isa(ex, Expr) + # elsewhere (e.g. the mandatory 2nd argument of a `:macrocall`) a `LineNumberNode` may + # be structurally required, so keep all positions and only recurse into nested blocks + return Expr(ex.head, Any[removeinternallinenumbernodes(e) for e in ex.args]...) + else + return ex + end +end + """ removelinenumbernode(ex) Remove all `LineNumberNode`s from an expression. + +!!! note + Kept for backwards compatibility. The parser now uses + [`removeinternallinenumbernodes`](@ref), which preserves user `LineNumberNode`s so that + generated code stays attributable to the user's source lines (e.g. for code coverage). """ function removelinenumbernode(ex) if isexpr(ex, :block) diff --git a/test/butensor.jl b/test/butensor.jl index fefcb211..ebb837cc 100644 --- a/test/butensor.jl +++ b/test/butensor.jl @@ -6,6 +6,23 @@ end using Bumper +@testset "@butensor preserves user line numbers (issue #280)" begin + # `@butensor` wraps the block in an inner `@tensor`; make sure it does not strip the user's + # line numbers, and does not leak TensorOperations-internal ones. + pkgsrc = dirname(pathof(TensorOperations)) + lnns = LineNumberNode[] + collect_lnns(x) = x isa LineNumberNode ? push!(lnns, x) : + x isa Expr && foreach(collect_lnns, x.args) + collect_lnns( + @macroexpand @butensor begin + T[a, b] := X[a, c] * Y[c, b] + Z[a, b] := T[a, c] * W[c, b] + end + ) + @test !any(l -> startswith(String(l.file), pkgsrc), lnns) + @test count(l -> String(l.file) == @__FILE__, lnns) >= 2 +end + @testset "Bumper tests with eltype $T" for T in (Float32, ComplexF64) D1, D2, D3 = 30, 40, 20 d1, d2 = 2, 3 diff --git a/test/macro_kwargs.jl b/test/macro_kwargs.jl index db577e49..b83d6879 100644 --- a/test/macro_kwargs.jl +++ b/test/macro_kwargs.jl @@ -92,6 +92,56 @@ end end end +# https://github.com/QuantumKitHub/TensorOperations.jl/issues/280: the generated code must keep +# the user's `LineNumberNode`s (so `@tensor` lines show up in code coverage) while dropping the +# parser's own internal ones (which would otherwise pollute the package's coverage). +@testset "line numbers (issue #280)" begin + collectlinenumbernodes(ex, acc = LineNumberNode[]) = + ( + ex isa LineNumberNode ? push!(acc, ex) : + ex isa Expr && foreach(e -> collectlinenumbernodes(e, acc), ex.args); acc + ) + pkgsrc = dirname(pathof(TensorOperations)) + pkglnns(lnns) = filter(l -> startswith(String(l.file), pkgsrc), lnns) + userlines(lnns) = sort!(unique!([l.line for l in lnns if String(l.file) == @__FILE__])) + + @testset "no internal LineNumberNodes leak into generated code" begin + # covers the scalar, dst-reuse and checkpoint `quote` paths in the parser + exprs = [ + @macroexpand(@tensor T[a, b] := A[a, c] * B[c, b]), + @macroexpand(@tensor R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, f] * F[f, b]), + @macroexpand(@tensor s = X[a, b] * Y[a, b]), + @macroexpand(@tensoropt R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, b]), + @macroexpand(@tensor allocator = alloc R[a, b] := A[a, c] * B[c, d] * C[d, b]), + @macroexpand(@tensor costcheck = warn R[a, b] := A[a, c] * B[c, d] * C[d, b]), + @macroexpand(@tensor contractcheck = true R[a, b] := A[a, c] * B[c, b]), + ] + for ex in exprs + @test isempty(pkglnns(collectlinenumbernodes(ex))) + end + end + + @testset "user LineNumberNodes are preserved per statement" begin + # multi-statement block, including a nested contraction whose intermediate is reused + block = @macroexpand @tensor begin + T[a, e] := A[a, c] * B[c, d] * C[d, e] + D[a, b] := T[a, e] * E[e, b] + s = D[a, b] * F[a, b] + end + lnns = collectlinenumbernodes(block) + @test isempty(pkglnns(lnns)) + @test length(userlines(lnns)) >= 3 # one distinct user line per statement + + optblock = @macroexpand @tensoropt begin + T[a, e] := A[a, c] * B[c, d] * C[d, e] + D[a, b] := T[a, e] * E[e, b] + end + optlnns = collectlinenumbernodes(optblock) + @test isempty(pkglnns(optlnns)) + @test length(userlines(optlnns)) >= 2 + end +end + @testset "opt" begin A = randn(5, 5, 5, 5) B = randn(5, 5, 5) From 6e7434ca6b39504977d2d9d558a6e4297debf93b Mon Sep 17 00:00:00 2001 From: lkdvos Date: Thu, 30 Jul 2026 13:22:09 -0400 Subject: [PATCH 2/2] Identify internal LineNumberNodes by the input expression, not by path Follow-up on the previous commit, addressing the two open questions in the PR discussion. Instead of recognizing parser-generated `LineNumberNode`s by string-prefix matching against `TensorOperations/src`, snapshot the set of files referenced by the `LineNumberNode`s of the expression handed to the parser -- taken before any preprocessor runs, so those are by construction exactly the user's line numbers -- and drop everything else. This also explains why the step cannot be a stateless entry in `parser.postprocessors`: it needs state captured before the preprocessors. The path-prefix version missed two cases: - `ext/`, which lies outside `src/`, so no extension was covered; - `@planar`/`@plansor`, where TensorKit's own pre/postprocessors synthesize `LineNumberNode`s pointing into `TensorKit/src/planar/`. Verified that those are now stripped as well, so `@planar` and `@plansor` are fixed with no TensorKit-side change. Note that both halves of this are needed, i.e. the removal is not merely cosmetic: a synthesized `LineNumberNode` in block-statement position re-attributes every statement that follows it to a line in TensorOperations' own source, which steals the attribution from the user's line. Conversely, `LineNumberNode`s outside block-statement position are left alone, since they can be structurally required -- the `costcheck = :warn` path emits a `@warn` macrocall whose second argument is one. Verified end to end with `--code-coverage`: on PEPSKit's `src/algorithms/contractions/ctmrg/enlarge_corner.jl` (the file from #280), the four contraction statements of `enlarge_northwest_corner` go from no coverage information at all to a hit count, taking the file from 12/72 to 28/88 reported lines. A deliberately-never-called function stays at 0 in both cases, confirming the counts reflect real execution. Also checked on the realistic CI path, i.e. `Pkg.test(coverage = true)` against a precompiled package rather than an `include`d file. While here: - delete `removelinenumbernode`, which is unexported and now unused; - drop the redundant removal in `insertcontractiontrees!`, since its output passes through the parser's final pass anyway; - update `docs/src/man/implementation.md`, which still listed `removelinenumbernode` as an enabled default postprocessor; - tighten the tests to assert the exact set of surviving line numbers and that every one of them comes from the test file, which the previous prefix-based assertion could not detect for `ext/`. Co-Authored-By: Claude Opus 5 (1M context) --- Project.toml | 2 +- docs/src/man/implementation.md | 6 +- src/indexnotation/contractiontrees.jl | 2 +- src/indexnotation/parser.jl | 6 +- src/indexnotation/postprocessors.jl | 72 ++++++++++---------- test/butensor.jl | 25 ++++--- test/macro_kwargs.jl | 95 ++++++++++++++++++--------- test/runtests.jl | 18 +++++ 8 files changed, 144 insertions(+), 82 deletions(-) diff --git a/Project.toml b/Project.toml index b442c669..caa37aae 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "TensorOperations" uuid = "6aa20fa7-93e2-5fca-9bc0-fbd0db3c71a2" -version = "5.6.2" +version = "5.6.3" authors = ["Lukas Devos ", "Maarten Van Damme ", "Jutho Haegeman "] [deps] diff --git a/docs/src/man/implementation.md b/docs/src/man/implementation.md index d5bc5b33..50902012 100644 --- a/docs/src/man/implementation.md +++ b/docs/src/man/implementation.md @@ -77,13 +77,17 @@ objects. ```@docs TensorOperations._flatten -TensorOperations.removelinenumbernode TensorOperations.addtensoroperations TensorOperations.insertargument TensorOperations.insertbackend TensorOperations.insertallocator ``` +Finally, after all postprocessors have run, the parser strips the `LineNumberNode`s that it +synthesized itself, while preserving the ones that came from the user's code. As a result the +generated code stays attributable to the lines of the original `@tensor` expression, which is +what makes those lines show up in code coverage reports and in stacktraces. + ## Analysis of contraction graphs and optimizing contraction order The macro [`@tensoropt`](@ref) or the combination of [`@tensor`](@ref) with the keyword diff --git a/src/indexnotation/contractiontrees.jl b/src/indexnotation/contractiontrees.jl index 3f203041..8d3bce05 100644 --- a/src/indexnotation/contractiontrees.jl +++ b/src/indexnotation/contractiontrees.jl @@ -161,7 +161,7 @@ function insertcontractiontrees!( end ) end - push!(postexprs, removeinternallinenumbernodes(costcompareex)) + push!(postexprs, costcompareex) return treeex end diff --git a/src/indexnotation/parser.jl b/src/indexnotation/parser.jl index 646729a4..7a0b6c4a 100644 --- a/src/indexnotation/parser.jl +++ b/src/indexnotation/parser.jl @@ -24,6 +24,9 @@ end function (parser::TensorParser)(ex::Expr) verifytensorexpr(ex) + # any `LineNumberNode` present here belongs to the user's code: record its file so that the + # ones synthesized further down can be told apart and removed again at the very end + userfiles = linenumberfiles(ex) for p in parser.preprocessors ex = p(ex)::Expr end @@ -35,7 +38,8 @@ function (parser::TensorParser)(ex::Expr) for p in parser.postprocessors ex = p(ex)::Expr end - ex = removeinternallinenumbernodes(ex)::Expr + # this has to happen after all (possibly user-supplied) postprocessors have run + ex = removeinternallinenumbernodes(ex, userfiles)::Expr return ex end diff --git a/src/indexnotation/postprocessors.jl b/src/indexnotation/postprocessors.jl index 8909dd58..ee03b5e8 100644 --- a/src/indexnotation/postprocessors.jl +++ b/src/indexnotation/postprocessors.jl @@ -30,57 +30,61 @@ function _flatten(ex) end end -# package source directory (with trailing separator), used to recognize `LineNumberNode`s that -# point into the parser's own `quote` blocks rather than into user code. -const _PARSER_SRCDIR = joinpath(dirname(@__DIR__), "") - -_isinternallinenumber(@nospecialize(x)) = - x isa LineNumberNode && startswith(String(x.file), _PARSER_SRCDIR) - """ - removeinternallinenumbernodes(ex) + linenumberfiles(ex, files = Set{Symbol}()) + +Collect the set of files referenced by the `LineNumberNode`s in `ex`. -Remove all `LineNumberNode`s that point into the TensorOperations source tree, i.e. the ones -introduced by the parser's own `quote` blocks. `LineNumberNode`s originating from user code are -kept, so that the generated code remains attributable to the user's source lines (e.g. for code -coverage). +This is used on the expression that is handed to a [`TensorParser`](@ref), before any +processing takes place, to determine which `LineNumberNode`s belong to the user's code: +see [`removeinternallinenumbernodes`](@ref). """ -function removeinternallinenumbernodes(ex) - if isexpr(ex, :block) - # within a block, `LineNumberNode`s are statement markers: drop the internal ones - args = Any[ - removeinternallinenumbernodes(e) for e in ex.args - if !_isinternallinenumber(e) - ] - return Expr(:block, args...) - elseif isa(ex, Expr) - # elsewhere (e.g. the mandatory 2nd argument of a `:macrocall`) a `LineNumberNode` may - # be structurally required, so keep all positions and only recurse into nested blocks - return Expr(ex.head, Any[removeinternallinenumbernodes(e) for e in ex.args]...) - else - return ex +function linenumberfiles(ex, files = Set{Symbol}()) + if ex isa LineNumberNode + push!(files, ex.file) + elseif ex isa Expr + foreach(e -> linenumberfiles(e, files), ex.args) end + return files end """ - removelinenumbernode(ex) + removeinternallinenumbernodes(ex, userfiles) -Remove all `LineNumberNode`s from an expression. +Remove the `LineNumberNode`s that were synthesized by the parser, i.e. the ones whose file is +not in `userfiles`, as obtained from [`linenumberfiles`](@ref) on the original expression. -!!! note - Kept for backwards compatibility. The parser now uses - [`removeinternallinenumbernodes`](@ref), which preserves user `LineNumberNode`s so that - generated code stays attributable to the user's source lines (e.g. for code coverage). +`LineNumberNode`s originating from user code are kept, so that the generated code remains +attributable to the user's source lines. This matters for code coverage: Julia only emits a +coverage counter for a line that a `LineNumberNode` points at, so stripping the user's +`LineNumberNode`s leaves every statement of a `@tensor begin ... end` block after the first one +without any coverage information at all. Conversely, a synthesized `LineNumberNode` would +re-attribute all statements that follow it to a line in the parser's own source, so both halves +are needed. """ -function removelinenumbernode(ex) +function removeinternallinenumbernodes(ex, userfiles) if isexpr(ex, :block) - args = [removelinenumbernode(e) for e in ex.args if !(e isa LineNumberNode)] + # within a block, `LineNumberNode`s are statement markers: drop the internal ones + args = Any[ + removeinternallinenumbernodes(e, userfiles) for e in ex.args + if !_isinternallinenumber(e, userfiles) + ] return Expr(:block, args...) + elseif isa(ex, Expr) + # elsewhere a `LineNumberNode` may be structurally required -- most notably as the + # mandatory 2nd argument of a `:macrocall` -- so keep all positions here and only + # recurse into nested blocks + return Expr( + ex.head, Any[removeinternallinenumbernodes(e, userfiles) for e in ex.args]... + ) else return ex end end +_isinternallinenumber(@nospecialize(x), userfiles) = + x isa LineNumberNode && x.file ∉ userfiles + # list of functions that are used in expressions produced by `@tensor` const tensoroperationsfunctions = ( :tensoralloc, :tensorfree!, diff --git a/test/butensor.jl b/test/butensor.jl index ebb837cc..ae658d09 100644 --- a/test/butensor.jl +++ b/test/butensor.jl @@ -7,20 +7,17 @@ end using Bumper @testset "@butensor preserves user line numbers (issue #280)" begin - # `@butensor` wraps the block in an inner `@tensor`; make sure it does not strip the user's - # line numbers, and does not leak TensorOperations-internal ones. - pkgsrc = dirname(pathof(TensorOperations)) - lnns = LineNumberNode[] - collect_lnns(x) = x isa LineNumberNode ? push!(lnns, x) : - x isa Expr && foreach(collect_lnns, x.args) - collect_lnns( - @macroexpand @butensor begin - T[a, b] := X[a, c] * Y[c, b] - Z[a, b] := T[a, c] * W[c, b] - end - ) - @test !any(l -> startswith(String(l.file), pkgsrc), lnns) - @test count(l -> String(l.file) == @__FILE__, lnns) >= 2 + # `@butensor` wraps the block in an inner `@tensor`, so the expansion has to be recursive + # here to reach the code that the parser generated. This also covers the extension itself: + # `_butensor` must not introduce `LineNumberNode`s pointing into `ext/`. + firstline = @__LINE__() + 2 + block = @macroexpand @butensor begin + T[a, b] := X[a, c] * Y[c, b] + Z[a, b] := T[a, c] * W[c, b] + end + lnns = statementlinenumbernodes(block) + @test all(l -> l.file === Symbol(@__FILE__), lnns) + @test sort!(unique(l.line for l in lnns)) == collect(firstline .+ (0:1)) end @testset "Bumper tests with eltype $T" for T in (Float32, ComplexF64) diff --git a/test/macro_kwargs.jl b/test/macro_kwargs.jl index b83d6879..5d45f13f 100644 --- a/test/macro_kwargs.jl +++ b/test/macro_kwargs.jl @@ -94,51 +94,86 @@ end # https://github.com/QuantumKitHub/TensorOperations.jl/issues/280: the generated code must keep # the user's `LineNumberNode`s (so `@tensor` lines show up in code coverage) while dropping the -# parser's own internal ones (which would otherwise pollute the package's coverage). +# parser's own, which would steal that attribution. See `statementlinenumbernodes` in +# `runtests.jl` for why only block-statement positions are inspected. `@macroexpand1` is used +# throughout so that we look at what `@tensor` itself produced, and not at the expansion of +# macros that it merely passes through (e.g. the `@warn` of the `costcheck` path). @testset "line numbers (issue #280)" begin - collectlinenumbernodes(ex, acc = LineNumberNode[]) = - ( - ex isa LineNumberNode ? push!(acc, ex) : - ex isa Expr && foreach(e -> collectlinenumbernodes(e, acc), ex.args); acc - ) - pkgsrc = dirname(pathof(TensorOperations)) - pkglnns(lnns) = filter(l -> startswith(String(l.file), pkgsrc), lnns) - userlines(lnns) = sort!(unique!([l.line for l in lnns if String(l.file) == @__FILE__])) - - @testset "no internal LineNumberNodes leak into generated code" begin - # covers the scalar, dst-reuse and checkpoint `quote` paths in the parser + thisfile = Symbol(@__FILE__) + + @testset "single-statement expressions carry no LineNumberNodes" begin + # nothing to preserve here: the input has no `LineNumberNode`s of its own, and the + # statement is attributed to the line of the `@tensor` call by the surrounding scope exprs = [ - @macroexpand(@tensor T[a, b] := A[a, c] * B[c, b]), - @macroexpand(@tensor R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, f] * F[f, b]), - @macroexpand(@tensor s = X[a, b] * Y[a, b]), - @macroexpand(@tensoropt R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, b]), - @macroexpand(@tensor allocator = alloc R[a, b] := A[a, c] * B[c, d] * C[d, b]), - @macroexpand(@tensor costcheck = warn R[a, b] := A[a, c] * B[c, d] * C[d, b]), - @macroexpand(@tensor contractcheck = true R[a, b] := A[a, c] * B[c, b]), + @macroexpand1(@tensor T[a, b] := A[a, c] * B[c, b]), + @macroexpand1(@tensor R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, f] * F[f, b]), + @macroexpand1(@tensor s = X[a, b] * Y[a, b]), + @macroexpand1(@tensoropt R[a, b] := A[a, c] * B[c, d] * C[d, e] * E[e, b]), + @macroexpand1(@tensor allocator = alloc R[a, b] := A[a, c] * B[c, d] * C[d, b]), + @macroexpand1(@tensor costcheck = warn R[a, b] := A[a, c] * B[c, d] * C[d, b]), + @macroexpand1(@tensor contractcheck = true R[a, b] := A[a, c] * B[c, b]), ] for ex in exprs - @test isempty(pkglnns(collectlinenumbernodes(ex))) + @test isempty(statementlinenumbernodes(ex)) end end - @testset "user LineNumberNodes are preserved per statement" begin - # multi-statement block, including a nested contraction whose intermediate is reused - block = @macroexpand @tensor begin + @testset "one user LineNumberNode per statement of a block" begin + # multi-statement block, including a scalar assignment; `s = ...` additionally + # exercises the `_flatten` path that hoists a block into the right hand side + firstline = @__LINE__() + 2 + block = @macroexpand1 @tensor begin T[a, e] := A[a, c] * B[c, d] * C[d, e] D[a, b] := T[a, e] * E[e, b] s = D[a, b] * F[a, b] end - lnns = collectlinenumbernodes(block) - @test isempty(pkglnns(lnns)) - @test length(userlines(lnns)) >= 3 # one distinct user line per statement + lnns = statementlinenumbernodes(block) + @test all(l -> l.file === thisfile, lnns) + @test sort!(unique(l.line for l in lnns)) == collect(firstline .+ (0:2)) + + # dst-reuse: `tensorify` wraps this in a `quote` of its own + reuseline = @__LINE__() + 2 + reuseblock = @macroexpand1 @tensor begin + T[a, b] := A[a, c] * B[c, b] + T[a, b] := T[a, c] * C[c, b] + end + reuselnns = statementlinenumbernodes(reuseblock) + @test all(l -> l.file === thisfile, reuselnns) + @test sort!(unique(l.line for l in reuselnns)) == collect(reuseline .+ (0:1)) - optblock = @macroexpand @tensoropt begin + optline = @__LINE__() + 2 + optblock = @macroexpand1 @tensoropt begin T[a, e] := A[a, c] * B[c, d] * C[d, e] D[a, b] := T[a, e] * E[e, b] end - optlnns = collectlinenumbernodes(optblock) - @test isempty(pkglnns(optlnns)) - @test length(userlines(optlnns)) >= 2 + optlnns = statementlinenumbernodes(optblock) + @test all(l -> l.file === thisfile, optlnns) + @test sort!(unique(l.line for l in optlnns)) == collect(optline .+ (0:1)) + end + + @testset "kwargs that generate extra code preserve user line numbers" begin + # `allocator` inserts a checkpoint `quote`, `costcheck` inserts a `@notensor` block + # containing a `@warn` whose own `LineNumberNode` is structurally required + allocline = @__LINE__() + 2 + allocblock = @macroexpand1 @tensor allocator = alloc begin + T[a, b] := A[a, c] * B[c, b] + Z[a, b] := T[a, c] * C[c, b] + end + alloclnns = statementlinenumbernodes(allocblock) + @test all(l -> l.file === thisfile, alloclnns) + @test sort!(unique(l.line for l in alloclnns)) == collect(allocline .+ (0:1)) + + costline = @__LINE__() + 2 + costblock = @macroexpand1 @tensor costcheck = warn begin + T[a, b] := A[a, c] * B[c, b] + Z[a, b] := T[a, c] * C[c, b] + end + costlnns = statementlinenumbernodes(costblock) + @test all(l -> l.file === thisfile, costlnns) + @test sort!(unique(l.line for l in costlnns)) == collect(costline .+ (0:1)) + # the `@warn` macrocall must have kept its (TensorOperations-internal) LineNumberNode, + # otherwise the expression cannot be expanded at all + @test macroexpand(@__MODULE__, costblock; recursive = true) isa Expr end end diff --git a/test/runtests.jl b/test/runtests.jl index 79432edd..55edbced 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -11,6 +11,24 @@ using TensorOperations: DefaultAllocator, ManualAllocator, BufferAllocator precision(::Type{<:Union{Float32, Complex{Float32}}}) = 1.0e-2 precision(::Type{<:Union{Float64, Complex{Float64}}}) = 1.0e-8 +# https://github.com/QuantumKitHub/TensorOperations.jl/issues/280: the generated code has to +# keep the user's `LineNumberNode`s -- Julia only emits a coverage counter for lines that a +# `LineNumberNode` points at -- and drop the ones synthesized by the parser, which would +# otherwise re-attribute the statements that follow them to a line in TensorOperations itself. +# Only `LineNumberNode`s in block-statement position matter for that: elsewhere (most notably +# the mandatory 2nd argument of a `:macrocall`) they are structurally required and left alone. +function statementlinenumbernodes(ex, acc = LineNumberNode[]) + if ex isa Expr + if ex.head === :block + for e in ex.args + e isa LineNumberNode && push!(acc, e) + end + end + foreach(e -> statementlinenumbernodes(e, acc), ex.args) + end + return acc +end + # don't run all tests on GPU, only the GPU # specific ones is_buildkite = get(ENV, "BUILDKITE", "false") == "true"