Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[epilogue] Autogenerate nicer data names by default, not just raw element names #7167

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import edu.wpi.first.epilogue.Logged;
import edu.wpi.first.epilogue.logging.ClassSpecificLogger;
import edu.wpi.first.epilogue.logging.DataLogger;
import java.util.stream.Collectors;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
Expand Down Expand Up @@ -61,14 +62,24 @@ protected TypeMirror dataType(Element element) {
* @return the name specified in the {@link Logged @Logged} annotation on the element, if present;
* otherwise, the field or method's name with no modifications
*/
public String loggedName(Element element) {
public static String loggedName(Element element) {
var elementName = element.getSimpleName().toString();
var config = element.getAnnotation(Logged.class);

if (config != null && !config.name().isBlank()) {
return config.name();
} else {
return elementName;
// Delete common field prefixes (k_Name, m_name, s_name)
elementName = elementName.replaceFirst("^[msk]_", "");
Copy link
Member

Choose a reason for hiding this comment

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

_ is also sometimes used as a prefix for private members

Copy link
Member Author

Choose a reason for hiding this comment

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

Good callout

if (elementName.matches("^k[A-Z]")) {
Copy link
Member

Choose a reason for hiding this comment

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

If Java's default regex engine supports lookaheads, this could be a replace with lookahead rather than an if statement.

Copy link
Member Author

Choose a reason for hiding this comment

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

This was to avoid replacing the first capital letter when this was part of the group above, eg "kName".replaceFirst("^k[A-Z]", "") would yield "ame" instead of "Name". Capture groups could also work, eg "kName".replaceFirst("^k([A-Z])", "$1")

Copy link
Member

Choose a reason for hiding this comment

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

That's why the lookahead -- ^k(?=[A-Z]) should work properly. Lookaheads match if the content is there but doesn't consume/include it into the match (and therefore won't be replaced).
Only risk is that some regex engines don't support lookaheads; I think Java's does (I saw some other Epilogue code using lookaheads).

// Drop leading "k" prefix from fields
// (though normally these should be static, and thus not logged)
elementName = elementName.substring(1);
}

return StringUtils.splitToWords(elementName).stream()
.map(StringUtils::capitalize)
.collect(Collectors.joining(" "));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,28 @@
import edu.wpi.first.epilogue.NotLogged;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.Name;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.tools.Diagnostic;

/** Generates logger class files for {@link Logged @Logged}-annotated classes. */
public class LoggerGenerator {
public static final Predicate<ExecutableElement> kIsBuiltInJavaMethod =
LoggerGenerator::isBuiltInJavaMethod;
private final ProcessingEnvironment m_processingEnv;
private final List<ElementHandler> m_handlers;

Expand All @@ -36,6 +45,19 @@ private static boolean isNotSkipped(Element e) {
return e.getAnnotation(NotLogged.class) == null;
}

/**
* Checks if a method is a method declared in java.lang.Object that should not be logged.
*
* @param e the method to check
* @return true if the method is toString, hashCode, or clone; false otherwise
*/
private static boolean isBuiltInJavaMethod(ExecutableElement e) {
Name name = e.getSimpleName();
return name.contentEquals("toString")
|| name.contentEquals("hashCode")
|| name.contentEquals("clone");
}

/**
* Generates the logger class used to handle data objects of the given type. The generated logger
* class will subclass from {@link edu.wpi.first.epilogue.logging.ClassSpecificLogger} and
Expand All @@ -53,15 +75,21 @@ public void writeLoggerFile(TypeElement clazz) throws IOException {
Predicate<Element> optedIn =
e -> !requireExplicitOptIn || e.getAnnotation(Logged.class) != null;

var fieldsToLog =
clazz.getEnclosedElements().stream()
.filter(e -> e instanceof VariableElement)
.map(e -> (VariableElement) e)
.filter(notSkipped)
.filter(optedIn)
.filter(e -> !e.getModifiers().contains(Modifier.STATIC))
.filter(this::isLoggable)
.toList();
List<VariableElement> fieldsToLog;
if (Objects.equals(clazz.getSuperclass().toString(), "java.lang.Record")) {
// Do not log record members - just use the accessor methods
fieldsToLog = List.of();
} else {
fieldsToLog =
clazz.getEnclosedElements().stream()
.filter(e -> e instanceof VariableElement)
.map(e -> (VariableElement) e)
.filter(notSkipped)
.filter(optedIn)
.filter(e -> !e.getModifiers().contains(Modifier.STATIC))
.filter(this::isLoggable)
.toList();
}

var methodsToLog =
clazz.getEnclosedElements().stream()
Expand All @@ -73,9 +101,50 @@ public void writeLoggerFile(TypeElement clazz) throws IOException {
.filter(e -> e.getModifiers().contains(Modifier.PUBLIC))
.filter(e -> e.getParameters().isEmpty())
.filter(e -> e.getReceiverType() != null)
.filter(kIsBuiltInJavaMethod.negate())
.filter(this::isLoggable)
.toList();

// Validate no name collisions
Map<String, List<Element>> usedNames =
Stream.concat(fieldsToLog.stream(), methodsToLog.stream())
.reduce(
new HashMap<>(),
(map, element) -> {
String name = ElementHandler.loggedName(element);
map.computeIfAbsent(name, _k -> new ArrayList<>()).add(element);

return map;
},
(left, right) -> {
left.putAll(right);
return left;
});

usedNames.forEach(
(name, elements) -> {
if (elements.size() > 1) {
// Collisions!
for (Element conflictingElement : elements) {
String conflicts =
elements.stream()
.filter(e -> !e.equals(conflictingElement))
.map(e -> e.getEnclosingElement().getSimpleName() + "." + e)
.collect(Collectors.joining(", "));

m_processingEnv
.getMessager()
.printMessage(
Diagnostic.Kind.ERROR,
"[EPILOGUE] Conflicting name detected: \""
+ name
+ "\" is also used by "
+ conflicts,
conflictingElement);
}
}
});

writeLoggerFile(clazz.getQualifiedName().toString(), config, fieldsToLog, methodsToLog);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
package edu.wpi.first.epilogue.processor;

import edu.wpi.first.epilogue.Logged;
import java.util.Arrays;
import java.util.List;
import javax.lang.model.element.TypeElement;

public final class StringUtils {
Expand Down Expand Up @@ -59,6 +61,33 @@ public static String lowerCamelCase(CharSequence str) {
return builder.toString();
}

/**
* Splits a camel-cased string like "fooBar" into individual words like ["foo", "Bar"].
*
* @param camelCasedString the camel-cased string to split
* @return the individual words in the input
*/
public static List<String> splitToWords(CharSequence camelCasedString) {
// Implementation from https://stackoverflow.com/a/2560017, refactored for readability

// Uppercase letter not followed by the first letter of the next word
// This allows for splitting "IOLayer" into "IO" and "Layer"
String penultimateUppercaseLetter = "(?<=[A-Z])(?=[A-Z][a-z])";

// Any character that's NOT an uppercase letter, immediately followed by an uppercase letter
// This allows for splitting "fooBar" into "foo" and "Bar", or "123Bang" into "123" and "Bang"
String lastNonUppercaseLetter = "(?<=[^A-Z])(?=[A-Z])";

// The final letter in a sequence, followed by a non-alpha character like a number or underscore
// This allows for splitting "foo123" into "foo" and "123"
String finalLetter = "(?<=[A-Za-z])(?=[^A-Za-z])";

String regex =
String.format("%s|%s|%s", penultimateUppercaseLetter, lastNonUppercaseLetter, finalLetter);

return Arrays.asList(camelCasedString.toString().split(regex));
}

/**
* Gets the name of the field used to hold a logger for data of the given type.
*
Expand Down
Loading
Loading