Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@Godin
Copy link
Member

@Godin Godin commented Aug 5, 2024

After recent #1525 and #1663, for resolution of #654 I propose to do another baby step, which IMO is quite significant from a Kotlin user point of view.

The following items can be improved later, so were deliberately excluded from this change


For src/main/kotlin/Example.kt

inline fun inlineIntoExample() {
  println("inline into example")
}

fun example() {
  inlineIntoExample()
}

inline fun inlineOnlyIntoTest() {
  println("inline into test");
}

and src/test/kotlin/ExampleTest.kt

import kotlin.test.Test

internal class ExampleTest {
  @Test
  fun test() {
    example()
    inlineOnlyIntoTest()
  }
}

from example.zip

execution of mvn verify as well as gradle test jacocoTestReport

before this change produces

before-class before-source

and after this change produces

after-class after-source

Fixes #654

@Godin
Copy link
Member Author

Godin commented Aug 9, 2024

@marchof @leveretka IMO this requires review from both of you 😉
Meanwhile I will provide results of testing on real-life projects.

@Godin Godin requested review from leveretka and marchof August 9, 2024 22:55
* Parsed representation of SourceDebugExtension attribute.
*/
final class KotlinSMAP {
public final class KotlinSMAP {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still Kotlin specific? Or should we move it to org.jacoco.core.internal.analysis.SMAP? This would resolve the dependency from the Analyzer on a Kotlin specific class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still Kotlin specific?

@marchof yes - it is still Kotlin specific.

@marchof
Copy link
Member

marchof commented Aug 15, 2024

@Godin I would like to draw some diagrams for my better understanding. Sorry for some stupid questions:

  • The SMAP describes the source of the inlined code, right?
  • This is a reference a source file and the line numbers herein, right?
  • We want to aggregate this in the ISourceFileCoverage? And also in IClassCoverage?

@Godin
Copy link
Member Author

Godin commented Aug 17, 2024

@marchof take your time to review and no worries - your questions usually lead to improvements 😉

SMAP of classfile contains list of KotlinSMAP.Mapping, each of which describes that lines from outputStartLine to outputStartLine + repeatCount - 1 in this class were inlined from (or using another term can say "mapped from") lines inputStartLine to inputStartLine + repeatCount - 1 of inputClassName class.

Note that there is also mapping of class with SMAP on itself - see condition

coverage.getName().equals(mapping.inputClassName()) && mapping.inputStartLine() == mapping.outputStartLine()

Currently KotlinInlineFilter marks all instructions corresponding to mapped lines that do not satisfy this condition as ignored, because they do not exist in the source file corresponding to this class file.

In this PR ClassAnalyzer.calculateFragments maps these lines back to their original line numbers and computes their line coverage

in accordance with https://www.jacoco.org/jacoco/trunk/doc/counters.html

line is considered executed when at least one instruction that is assigned to this line has been executed

result is stored inside current ClassCoverageImpl as lines of a so-called "fragment" (as it is a kind of fragment of another class file) SourceNodeImpl with name of original class.

Then later, after all the classes were observed, CoverageBuilder applies fragments on their corresponding (original) classes by updating counters in corresponding classes, methods and lines - see ClassCoverageImpl.applyFragment, MethodCoverageImpl.applyFragment and SourceNodeImpl.applyFragment.

After that as before CoverageBuilder aggregates ClassCoverageImpl nodes into SourceFileCoverageImpl nodes, and BundleCoverageImpl groups them into PackageCoverageImpl nodes.

So that at the end we receive updated counters on all levels - bundle, package, class, method, source, line.

Also in this PR KotlinGeneratedFilter was updated to mark instructions not associated with line numbers as ignored, because mappings can not be provided for them, whereas they are present at the beginning of inlined (original) methods.

Hope I understood well your current questions and the above answers them and clarifies things.

@marchof
Copy link
Member

marchof commented Aug 18, 2024

@Godin I created a description of our current coverage model: https://github.com/jacoco/jacoco/wiki/CoverageDataModel

I will create another document describing the additional requirements for inlining.

@marchof
Copy link
Member

marchof commented Aug 18, 2024

@Godin @leveretka Out of curiosity: How do debuggers deal with inline functions?

@marchof
Copy link
Member

marchof commented Aug 18, 2024

@Godin Thanks for your detailed explanation. I try to write some details here: https://github.com/jacoco/jacoco/wiki/CoverageDateModelForInlining

So SMAP maps line numbers which in turn are probably mapped to instructions with the regular LineNumberTable, right? So I try to understand some corner cases:

  • If two inlined methods would be defined on the same line you can not tell the apart when one of them is compiled into another method, right?
  • What happens when two inline method are executed in a single line, like condA() && condB()? Can we tell which code belongs to which method?

@Godin
Copy link
Member Author

Godin commented Aug 19, 2024

@marchof

How do debuggers deal with inline functions?

AFAIK debugger in IntelliJ IDEA uses SMAPs and additional information that we do not - we use only Kotlin main SMAP stratum, whereas debugger also uses SMAP stratum KotlinDebug and special LocalVariableTable entries - see https://github.com/JetBrains/intellij-community/blob/0b286c65baf3d6ea70456db4703208f158791998/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/core/stackFrame/InlineStackTraceCalculator.kt

SMAP maps line numbers which in turn are probably mapped to instructions with the regular LineNumberTable, right?

Not probably - for sure 😉 see condition

mapping.outputStartLine() <= instruction.getLine() && instruction.getLine() <= mappingOutputEndLine

in ClassAnalyzer. calculateFragments.

What happens when two inline method are executed in a single line, like condA() && condB()? Can we tell which code belongs to which method?

Seems that you miss an important point:

Inlined instructions receive generated line numbers greater than originally presented in source and SMAP provides mappings for these generated line numbers.

We implemented KotlinInlineFilter exactly because their line numbers are greater than actually presented in source - recall #764

And as far as I can see different inlines, even of the same original function, receive different generated line numbers.

So yes - in this case we can tell which instructions belong to which original method.

For example

for src/Stubs.kt

val a = false;
inline fun condA(): Boolean = a; // line 2

val b = true;
inline fun condB(): Boolean = b; // line 5

and src/Example.kt

fun main() {
  if (condA() && condB()) { // line 2
    println();
  }
} // line 5

execution of

kotlin-2.0.0/bin/kotlinc src/main -d classes
javap -v -p classes/ExampleKt.class

produces

  public static final void main();
    descriptor: ()V
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    Code:
      stack=1, locals=1, args_size=0
         0: iconst_0
         1: istore_0
         2: invokestatic  #12                 // Method StubsKt.getA:()Z
         5: ifeq          22
         8: iconst_0
         9: istore_0
        10: invokestatic  #15                 // Method StubsKt.getB:()Z
        13: ifeq          22
        16: getstatic     #21                 // Field java/lang/System.out:Ljava/io/PrintStream;
        19: invokevirtual #26                 // Method java/io/PrintStream.println:()V
        22: return
      StackMapTable: number_of_entries = 1
        frame_type = 252 /* append */
          offset_delta = 22
          locals = [ int ]
      LineNumberTable:
        line 2: 0
        line 7: 2
        line 2: 5
        line 10: 10
        line 2: 13
        line 3: 16
        line 5: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            2       3     0 $i$f$condA   I
           10       3     0 $i$f$condB   I

SourceDebugExtension:
  SMAP
  Example.kt
  Kotlin
  *S Kotlin
  *F
  + 1 Example.kt
  ExampleKt
  + 2 Stub.kt
  StubKt
  *L
  1#1,6:1
  2#2,4:7
  *S KotlinDebug
  *F
  + 1 Example.kt
  ExampleKt
  *L
  2#1:7,4
  *E

LineNumberTable contains line 7 and line 10 which are not present in Example.kt,
as well as line 2 which is present in Example.kt.

SourceDebugExtension contains information that line 7 maps to line 2 and line 10 maps to line 4 in StubsKt.

And execution of

java -javaagent:jacoco-0.8.13-SNAPSHOT/lib/jacocoagent.jar -cp classes ExampleKt
java -jar jacoco-0.8.13-SNAPSHOT/lib/jacococli.jar report jacoco.exec --classfiles classes --sourcefiles src --html report

produces following report

Screenshot 2024-08-19 at 15 50 09 Screenshot 2024-08-19 at 15 49 54

BTW our setup for validation tests provides nice way to do experiments 😉

If two inlined methods would be defined on the same line you can not tell the apart when one of them is compiled into another method, right?

Indeed. In this case line coverage will be correct with respect to its definition, but not method coverage.

BTW https://github.com/JetBrains/intellij-coverage has the same limitation:

Screenshot 2024-08-19 at 15 51 15

Maybe this can be improved using more information like debugger does, but there are issues such as https://youtrack.jetbrains.com/issue/KT-60276/Support-debugging-inline-functions-on-Android

And IMO highly unlikely that functions in non-generated code will be defined on the same line.

So IMO not worth it and this is acceptable limitation, at least right now.

Let me also introduce/CC @zuevmaxim from JetBrains who AFAIK works on intellij-coverage, debugger and JaCoCo integration 🖖

Also let me refer to "perfection is the enemy of progress" and "perfect is the enemy of good" here and 80/20 Pareto principle in a sense that IMO efforts to deal with this corner case are much higher than benefits 😉

@Godin
Copy link
Member Author

Godin commented Aug 29, 2024

Meanwhile I will provide results of testing on real-life projects.

I looked at some popular open-source projects, and the results matched expectations and our validation tests.
To find differences in reports I used XML diff written some time ago - #811
And was not able to find any unexpected differences.

For example for https://github.com/detekt/detekt

inline before this change

Markdown-before
XmlExtensions-before

inline after this change

Markdown-after
XmlExtensions-after

crossinline before this change

MaxChainedCallsOnSameLine-before

crossinline after this change

MacChainedCallsOnSameLine-after

Copy link
Collaborator

@leveretka leveretka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice to see that this subject is moving. It was a long-awaited enhancement in the Kotlin community. And we're looking forward to the next steps.

From my side, everything looks fine. The results look indeed impressive. Thanks @Godin for taking care of this!

I wanted to check if @marchof has any concerns before giving my approval.

@marchof
Copy link
Member

marchof commented Sep 1, 2024

@Godin @leveretka Thanks for moving forward here! Unfortunately my day job did not allow me to spend enough time here to fully understand the approach. To not block this I propose that we merge this and fix/cleanup things that we discover later.

I have one question though that bugs me: If I understand correctly, the inlined code is analyzed separately and then counters are merged later. How is merging instructions and branches possible based on counters? Let's say two call-sites both have a branch counter of 1/1. Depending on whether the same branch or a different branch was executed in both call-sites the combined coverage is 1/1 or 0/2.

I would have expected that proper merging coverage for inline functions would require knowledge of the execution status of every branch and instruction. Something like we have today for filters (IFilterOutput.merge()).

@Godin
Copy link
Member Author

Godin commented Sep 2, 2024

@marchof regarding

I have one question though that bugs me: If I understand correctly, the inlined code is analyzed separately and then counters are merged later. How is merging instructions and branches possible based on counters? Let's say two call-sites both have a branch counter of 1/1. Depending on whether the same branch or a different branch was executed in both call-sites the combined coverage is 1/1 or 0/2.

I would have expected that proper merging coverage for inline functions would require knowledge of the execution status of every branch and instruction. Something like we have today for filters (IFilterOutput.merge()).

you're right, and in order to step-by-step progress on this not-so-small subject, I explicitly split it and among other items branch coverage was explicitly excluded and this PR focuses only on line coverage as stated in its title and description

Calculate line coverage for Kotlin inline functions
...
The following items can be improved later, so were deliberately excluded from this change

...

as well as in the entry added to the changelog.

Also IMO from a Kotlin user point of view, even without branch coverage the ability to see line coverage for inline functions is quite a significant improvement on its own - recall that JaCoCo was not reporting branch coverage at all till version 0.5.0 😉

@marchof
Copy link
Member

marchof commented Sep 3, 2024

@Godin Thanks for the explanation! While digging into the implementation details I obviously totally missed the objective in the headline 🙈.

So please move forward with this!

@Godin Godin added this to the 0.8.13 milestone Sep 4, 2024
@Godin Godin marked this pull request as ready for review September 4, 2024 21:51
@Godin Godin enabled auto-merge (squash) September 4, 2024 21:52
@leveretka leveretka self-requested a review September 4, 2024 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

Kotlin inline functions are not marked as covered

3 participants