com.google.javascript.jscomp.instrumentation.reporter.ReportDecoder Maven / Gradle / Ivy
Show all versions of closure-compiler Show documentation
/*
* Copyright 2020 The Closure Compiler Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.javascript.jscomp.instrumentation.reporter;
import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Comparator.comparing;
import com.google.common.base.Splitter;
import com.google.common.io.CharStreams;
import com.google.debugging.sourcemap.Base64VLQ;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.google.javascript.jscomp.instrumentation.reporter.proto.FileProfile;
import com.google.javascript.jscomp.instrumentation.reporter.proto.InstrumentationPoint;
import com.google.javascript.jscomp.instrumentation.reporter.proto.InstrumentationPointStats;
import com.google.javascript.jscomp.instrumentation.reporter.proto.InstrumentationPointStats.Presence;
import com.google.javascript.jscomp.instrumentation.reporter.proto.ReportProfile;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Class that helps to decode reports received from JS binaries that have been instrumented. Report
* is usually a map of String => Long. Where key is encoded instrumentation point and value is
* number of times that code was executed. To decode report beside report itself we need mapping
* file that was produced when JS binary was compiled.
*
* Expected usage: {@code
* Map mapping = ReportDecoder.parseMappingFromFile("mapping.txt");
* Map encodedReport = readFile("report.txt");
* ReportProfile report = ReportDecoder.decodeReport(mapping, encodedReport);
* }
*
*/
public final class ReportDecoder {
private ReportDecoder() {}
public static Map parseMappingFromFile(String mappingFilePath)
throws IOException {
String fileContent =
CharStreams.toString(Files.newBufferedReader(Path.of(mappingFilePath), UTF_8));
return ReportDecoder.parseMapping(fileContent);
}
/**
* Parses the file found at location mappingFilePath and populates the properties of this class
*/
public static Map parseMapping(String mappingFileContent) {
Map mapping = new LinkedHashMap<>();
List linesOfFile = Splitter.on('\n').omitEmptyStrings().splitToList(mappingFileContent);
// Report should contain at least three lines
checkState(linesOfFile.size() >= 3, "Malformed report %s", linesOfFile);
String fileNamesLine = linesOfFile.get(0).trim();
String functionNamesLine = linesOfFile.get(1).trim();
String typesLine = linesOfFile.get(2).trim();
checkState(fileNamesLine.startsWith("FileNames:"));
checkState(functionNamesLine.startsWith("FunctionNames:"));
checkState(typesLine.startsWith("Types:"));
String fileNamesAsJsonArray = fileNamesLine.substring(fileNamesLine.indexOf(":") + 1);
String functionNamesAsJsonArray =
functionNamesLine.substring(functionNamesLine.indexOf(":") + 1);
String typesAsJsonArray = typesLine.substring(typesLine.indexOf(":") + 1);
Type stringListType = new TypeToken>() {}.getType();
Gson gson = new GsonBuilder().disableHtmlEscaping().create();
List localFileNames = gson.fromJson(fileNamesAsJsonArray, stringListType);
List functionNames = gson.fromJson(functionNamesAsJsonArray, stringListType);
List typesAsStringList = gson.fromJson(typesAsJsonArray, stringListType);
List types =
typesAsStringList.stream()
.map(InstrumentationPoint.Type::valueOf)
.collect(Collectors.toList());
for (int i = 3; i < linesOfFile.size(); ++i) {
String lineItem = linesOfFile.get(i);
String id = lineItem.substring(0, lineItem.indexOf(':'));
String encodedDetails = lineItem.substring(lineItem.indexOf(':') + 1);
StringCharIterator encodedDetailsAsCharIt = new StringCharIterator(encodedDetails);
InstrumentationPoint temp =
InstrumentationPoint.newBuilder()
.setFileName(localFileNames.get(Base64VLQ.decode(encodedDetailsAsCharIt)))
.setFunctionName(functionNames.get(Base64VLQ.decode(encodedDetailsAsCharIt)))
.setType(types.get(Base64VLQ.decode(encodedDetailsAsCharIt)))
.setLineNumber(Base64VLQ.decode(encodedDetailsAsCharIt))
.setColumnNumber(Base64VLQ.decode(encodedDetailsAsCharIt))
.build();
mapping.putIfAbsent(id, temp);
}
return mapping;
}
public static ReportProfile decodeReport(
Map mapping, Map frequencies) {
// Decode each entry into InstrumentionPoint.
Stream instrumentationPoints =
mapping.entrySet().stream()
.map(
(entry) ->
InstrumentationPointStats.newBuilder()
.setPoint(entry.getValue())
.setTimesExecuted(frequencies.getOrDefault(entry.getKey(), 0L))
.build());
return ReportDecoder.createReportProfile(instrumentationPoints);
}
/**
* This function builds a report for given JS binary analyzing its content. If an instrumentation
* point is present in the JS binary - it will be marked as executed once. If the instrumentation
* point was removed by the compiler as dead code - it will be marked as executed zero times. This
* function doesn't give 100% guarantee for finding unused code but it provides good enough
* approximation to be useful.
*
* @param mapping Parsed instrumentation mapping. Use {@link #parseMapping} function to create it.
* @param fileContent Content of a JS binary compiled with production instrumentation enabled.
* @param instrumentationArrayName Name of the array that was passed as
* --production_instrumentation_array_name flag when compiling JS binary.
* @return Report containing all instrumentation points. If a point times_executed is zero - it
* means the function or branch was removed as dead code. If it's one - it's present in the JS
* binary.
*/
public static ReportProfile createProfileOfStaticallyUsedCode(
Map mapping,
String fileContent,
String instrumentationArrayName) {
Matcher matcher =
Pattern.compile(instrumentationArrayName + "\\.push\\(['\"](.*?)['\"]\\)")
.matcher(fileContent);
final HashSet encodedPoints = new HashSet<>();
while (matcher.find()) {
encodedPoints.add(matcher.group(1));
}
Stream instrumentationPoints =
mapping.entrySet().stream()
.map(
(entry) ->
InstrumentationPointStats.newBuilder()
.setPoint(entry.getValue())
.setTimesExecuted(0)
.setPointPresence(
encodedPoints.contains(entry.getKey())
? Presence.PRESENT
: Presence.STATICALLY_REMOVED)
.build());
return ReportDecoder.createReportProfile(instrumentationPoints);
}
/**
* Given list of profiles merges them, summing up execution counts for instrumentation points used
* across multiple profiles. Be aware that instrumentation points are compared as equality so
* profiles should come from JS binaries compiled with at the same commit, otherwise changes in
* source code will cause differences in some instrumentation points.
*/
public static ReportProfile mergeProfiles(List profiles) {
Map frequencies = new HashMap<>();
Map presences = new HashMap<>();
for (ReportProfile profile : profiles) {
for (FileProfile fileProfile : profile.getFileProfileList()) {
for (InstrumentationPointStats pointStats :
fileProfile.getInstrumentationPointsStatsList()) {
frequencies.compute(
pointStats.getPoint(),
(p, existing) -> (existing == null ? 0 : existing) + pointStats.getTimesExecuted());
// Logic of merging presence field.
// 1. PRESENT overrides STATICALLY_REMOVED and UNKNOWN.
// 2. STATICALLY_REMOVED overrides UNKNOWN.
presences.compute(
pointStats.getPoint(),
(p, existing) ->
(pointStats.getPointPresence() == Presence.PRESENT
|| existing == Presence.PRESENCE_UNKNOWN
|| existing == null)
? pointStats.getPointPresence()
: existing);
}
}
}
Stream finalPoints =
frequencies.entrySet().stream()
.map(
(Map.Entry entry) ->
InstrumentationPointStats.newBuilder()
.setPoint(entry.getKey())
.setTimesExecuted(entry.getValue())
.setPointPresence(presences.get(entry.getKey()))
.build());
return ReportDecoder.createReportProfile(finalPoints);
}
/**
* Groups instrumentation points by files and convert them to FileProfile object, one per file and
* return as ReportProfile.
*/
private static ReportProfile createReportProfile(
Stream instrumentationPoints) {
List fileProfiles =
instrumentationPoints
.sorted(
comparing(
(InstrumentationPointStats pointStats) ->
pointStats.getPoint().getFileName())
.thenComparingInt(
(InstrumentationPointStats pointStats) ->
pointStats.getPoint().getLineNumber()))
.collect(
Collectors.groupingBy(
(InstrumentationPointStats pointStats) -> pointStats.getPoint().getFileName()))
.entrySet()
.stream()
.map(
(entry) ->
FileProfile.newBuilder()
.setFileName(entry.getKey())
.addAllInstrumentationPointsStats(entry.getValue())
.build())
.sorted(comparing(FileProfile::getFileName))
.collect(Collectors.toList());
return ReportProfile.newBuilder().addAllFileProfile(fileProfiles).build();
}
/**
* A implementation of the Base64VLQ CharIterator used for decoding the mappings encoded in the
* JSON string.
*/
private static class StringCharIterator implements Base64VLQ.CharIterator {
final String content;
final int length;
int current = 0;
StringCharIterator(String content) {
this.content = content;
this.length = content.length();
}
@Override
public char next() {
return content.charAt(current++);
}
@Override
public boolean hasNext() {
return current < length;
}
}
}