Skip to content
Merged
7 changes: 6 additions & 1 deletion core/src/main/java/dev/vml/es/acm/core/code/Code.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import org.apache.sling.event.jobs.Job;

/**
* Represents a code that can be executed.
* Represents any code (e.g text from interactive console) that can be executed.
*/
public class Code implements Executable {

Expand Down Expand Up @@ -61,6 +61,11 @@ public String getContent() {
return content;
}

@Override
public CodeMetadata getMetadata() {
return CodeMetadata.of(this);
}

public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("id", id)
Expand Down
162 changes: 162 additions & 0 deletions core/src/main/java/dev/vml/es/acm/core/code/CodeMetadata.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package dev.vml.es.acm.core.code;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class CodeMetadata implements Serializable {

public static final CodeMetadata EMPTY = new CodeMetadata(new LinkedHashMap<>());

private static final Logger LOG = LoggerFactory.getLogger(CodeMetadata.class);

private static final Pattern DOC_COMMENT_PATTERN = Pattern.compile("/\\*\\*([^*]|\\*(?!/))*\\*/", Pattern.DOTALL);
private static final Pattern TAG_PATTERN =
Pattern.compile("(?m)^\\s*\\*?\\s*@(\\w+)\\s+(.+?)(?=(?m)^\\s*\\*?\\s*@\\w+|\\*/|$)", Pattern.DOTALL);
private static final Pattern NEWLINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n[\\s\\S]*");
private static final Pattern BLANK_LINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n\\s*\\n[\\s\\S]*");
private static final Pattern IMPORT_OR_PACKAGE_BEFORE =
Pattern.compile("[\\s\\S]*(import|package)[\\s\\S]*\\n\\s*\\n\\s*$");
private static final Pattern FIRST_TAG_PATTERN = Pattern.compile("(?m)^\\s*\\*?\\s*@\\w+");
private static final Pattern LEADING_ASTERISK = Pattern.compile("(?m)^\\s*\\*\\s?");
private static final Pattern DOC_MARKERS = Pattern.compile("^/\\*\\*|\\*/$");

private Map<String, Object> values;

public CodeMetadata(Map<String, Object> values) {
this.values = values;
}

public static CodeMetadata of(Executable executable) {
try {
return parse(executable.getContent());
} catch (Exception e) {
LOG.warn("Cannot parse code metadata from executable '{}'!", executable.getId(), e);
return EMPTY;
}
}

public static CodeMetadata parse(String code) {
if (StringUtils.isNotBlank(code)) {
String docComment = findFirstDocComment(code);
if (docComment != null) {
return new CodeMetadata(parseDocComment(docComment));
}
}
return EMPTY;
}

/**
* Finds first JavaDoc/GroovyDoc comment that's properly separated with blank lines,
* or directly attached to describeRun() method.
*/
private static String findFirstDocComment(String code) {
Matcher matcher = DOC_COMMENT_PATTERN.matcher(code);

while (matcher.find()) {
String comment = matcher.group();
int commentStart = matcher.start();
int commentEnd = matcher.end();

String afterComment = code.substring(commentEnd);

if (!NEWLINE_AFTER_COMMENT.matcher(afterComment).matches()) {
continue;
}

String trimmedAfter = afterComment.trim();

if (trimmedAfter.startsWith("void describeRun()")) {
return comment;
}

if (!BLANK_LINE_AFTER_COMMENT.matcher(afterComment).matches()) {
continue;
}

if (commentStart > 0) {
String beforeComment = code.substring(0, commentStart);
String trimmedBefore = beforeComment.trim();
if (trimmedBefore.isEmpty()
|| IMPORT_OR_PACKAGE_BEFORE.matcher(beforeComment).matches()) {
return comment;
}
} else {
return comment;
}
}

return null;
}

/**
* Extracts description and @tags from doc comment. Supports multiple values per tag.
*/
private static Map<String, Object> parseDocComment(String docComment) {
Map<String, Object> result = new LinkedHashMap<>();

String content = DOC_MARKERS.matcher(docComment).replaceAll("");

// @ at line start (not in email addresses)
Matcher firstTagMatcher = FIRST_TAG_PATTERN.matcher(content);

if (firstTagMatcher.find()) {
int firstTagIndex = firstTagMatcher.start();
String description = LEADING_ASTERISK
.matcher(content.substring(0, firstTagIndex))
.replaceAll("")
.trim();
if (!description.isEmpty()) {
result.put("description", description);
}
} else {
String description =
LEADING_ASTERISK.matcher(content).replaceAll("").trim();
if (!description.isEmpty()) {
result.put("description", description);
}
}

Matcher tagMatcher = TAG_PATTERN.matcher(content);

while (tagMatcher.find()) {
String tagName = tagMatcher.group(1);
String tagValue = tagMatcher.group(2);

if (tagValue != null && !tagValue.isEmpty()) {
tagValue = LEADING_ASTERISK.matcher(tagValue).replaceAll("").trim();

if (!tagValue.isEmpty()) {
Object existing = result.get(tagName);

if (existing == null) {
result.put(tagName, tagValue);
} else if (existing instanceof List) {
@SuppressWarnings("unchecked")
List<String> list = (List<String>) existing;
list.add(tagValue);
} else {
List<String> list = new ArrayList<>();
list.add((String) existing);
list.add(tagValue);
result.put(tagName, list);
}
}
}
}
return result;
}

@JsonAnyGetter
public Map<String, Object> getValues() {
return values;
}
}
5 changes: 3 additions & 2 deletions core/src/main/java/dev/vml/es/acm/core/code/Executable.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package dev.vml.es.acm.core.code;

import dev.vml.es.acm.core.AcmException;
import java.io.Serializable;

public interface Executable extends Serializable {
Expand All @@ -11,5 +10,7 @@ public interface Executable extends Serializable {

String getId();

String getContent() throws AcmException;
String getContent();

CodeMetadata getMetadata();
}
6 changes: 6 additions & 0 deletions core/src/main/java/dev/vml/es/acm/core/script/Script.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.annotation.JsonIgnore;
import dev.vml.es.acm.core.AcmException;
import dev.vml.es.acm.core.code.CodeMetadata;
import dev.vml.es.acm.core.code.Executable;
import java.io.IOException;
import java.io.InputStream;
Expand Down Expand Up @@ -55,6 +56,11 @@ public String getContent() throws AcmException {
}
}

@Override
public CodeMetadata getMetadata() {
return CodeMetadata.of(this);
}

@JsonIgnore
public String getPath() {
return resource.getPath();
Expand Down
116 changes: 116 additions & 0 deletions core/src/test/java/dev/vml/es/acm/core/code/CodeMetadataTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package dev.vml.es.acm.core.code;

import static org.junit.jupiter.api.Assertions.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import org.junit.jupiter.api.Test;

class CodeMetadataTest {

private static final Path SCRIPTS_BASE_PATH =
Paths.get("../ui.content.example/src/main/content/jcr_root/conf/acm/settings/script");

private String readScript(String relativePath) throws IOException {
Path scriptPath = SCRIPTS_BASE_PATH.resolve(relativePath);
return new String(Files.readAllBytes(scriptPath), StandardCharsets.UTF_8);
}

@Test
void shouldParseEmptyCode() {
CodeMetadata metadata = CodeMetadata.parse("");

assertTrue(metadata.getValues().isEmpty());
}

@Test
void shouldParseNullCode() {
CodeMetadata metadata = CodeMetadata.parse(null);

assertTrue(metadata.getValues().isEmpty());
}

@Test
void shouldParseHelloWorldScript() throws IOException {
String code = readScript("manual/example/ACME-200_hello-world.groovy");
CodeMetadata metadata = CodeMetadata.parse(code);

assertEquals(
"Prints \"Hello World!\" to the console.", metadata.getValues().get("description"));
}

@Test
void shouldParseInputsScript() throws IOException {
String code = readScript("manual/example/ACME-201_inputs.groovy");
CodeMetadata metadata = CodeMetadata.parse(code);

String description = (String) metadata.getValues().get("description");
assertNotNull(description);
assertTrue(description.contains("Prints animal information to the console based on user input"));
assertEquals("<john.doe@acme.com>", metadata.getValues().get("author"));
}

@Test
void shouldParsePageThumbnailScript() throws IOException {
String code = readScript("manual/example/ACME-202_page-thumbnail.groovy");
CodeMetadata metadata = CodeMetadata.parse(code);
String description = (String) metadata.getValues().get("description");

assertNotNull(description);
assertTrue(description.contains("Updates the thumbnail"));
assertTrue(description.contains("File must be a JPEG image"));
assertEquals("<john.doe@acme.com>", metadata.getValues().get("author"));
}

@Test
void shouldParseScriptWithoutDocComment() throws IOException {
String code = readScript("automatic/example/ACME-20_once.groovy");
CodeMetadata metadata = CodeMetadata.parse(code);

assertTrue(metadata.getValues().isEmpty());
}

@Test
void shouldParseMultipleAuthors() {
String code = "/**\n" + " * @author John Doe\n"
+ " * @author Jane Smith\n"
+ " */\n"
+ "\n"
+ "void doRun() {\n"
+ " println \"Hello\"\n"
+ "}";
CodeMetadata metadata = CodeMetadata.parse(code);
Object authors = metadata.getValues().get("author");
assertTrue(authors instanceof List);
@SuppressWarnings("unchecked")
List<String> authorsList = (List<String>) authors;

assertEquals(2, authorsList.size());
assertEquals("John Doe", authorsList.get(0));
assertEquals("Jane Smith", authorsList.get(1));
}

@Test
void shouldParseCustomTags() {
String code = "/**\n" + " * @description Custom script with metadata\n"
+ " * @version 1.0.0\n"
+ " * @since 2025-01-01\n"
+ " * @category migration\n"
+ " */\n"
+ "\n"
+ "void doRun() {\n"
+ " println \"Hello\"\n"
+ "}";

CodeMetadata metadata = CodeMetadata.parse(code);

assertEquals("Custom script with metadata", metadata.getValues().get("description"));
assertEquals("1.0.0", metadata.getValues().get("version"));
assertEquals("2025-01-01", metadata.getValues().get("since"));
assertEquals("migration", metadata.getValues().get("category"));
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* This script creates content author groups for each tenant-country-language combination.
*
* The groups are named in the format: `{tenant}-{country}-{language}-content-authors`.
* Each group is granted read, write, and replicate permissions on the corresponding content and DAM paths.
*/

def scheduleRun() {
return schedules.cron("0 10 * ? * * *") // every hour at minute 10
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - print the list (for debugging purposes),
* - save it directly in the repository in expected path.
*
* @author Krystian Panek <krystian.panek@vml.com>
* @author <john.doe@acme.com>
*/

import dev.vml.es.acm.core.assist.JavaDictionary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - print the list (for debugging purposes),
* - save it directly in the repository in expected path.
*
* @author Krystian Panek <krystian.panek@vml.com>
* @author <john.doe@acme.com>
*/

import dev.vml.es.acm.core.assist.JavaDictionary
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/**
* Prints "Hello World!" to the console.
* Prints "Hello World!" to the console.
*
* This is a minimal example of AEM Content Manager script.
*
* @author Krystian Panek <krystian.panek@vml.com>
* @author <john.doe@acme.com>
*/

boolean canRun() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
/**
* Prints animal information to the console based on user input.
*
* This is an example of AEM Content Manager script with inputs.
*
* @author Krystian Panek <krystian.panek@vml.com>
*
* @author <john.doe@acme.com>
*/

void describeRun() {
inputs.string("animalName") { value = "Whiskers";
validator = "(v, a) => a.animalType === 'cat' ? (v && v.startsWith('W') || 'Cat name must start with W!') : true" }
Expand Down
Loading