Skip to content

Commit 0412127

Browse files
Merge pull request #275 from wttech/code-metadata
Executable metadata
2 parents 729e097 + ae95b2e commit 0412127

File tree

23 files changed

+604
-83
lines changed

23 files changed

+604
-83
lines changed

core/src/main/java/dev/vml/es/acm/core/code/Code.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import org.apache.sling.event.jobs.Job;
99

1010
/**
11-
* Represents a code that can be executed.
11+
* Represents any code (e.g text from interactive console) that can be executed.
1212
*/
1313
public class Code implements Executable {
1414

@@ -61,6 +61,11 @@ public String getContent() {
6161
return content;
6262
}
6363

64+
@Override
65+
public CodeMetadata getMetadata() {
66+
return CodeMetadata.of(this);
67+
}
68+
6469
public String toString() {
6570
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
6671
.append("id", id)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package dev.vml.es.acm.core.code;
2+
3+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
4+
import java.io.Serializable;
5+
import java.util.ArrayList;
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.regex.Matcher;
10+
import java.util.regex.Pattern;
11+
import org.apache.commons.lang3.StringUtils;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
15+
public class CodeMetadata implements Serializable {
16+
17+
public static final CodeMetadata EMPTY = new CodeMetadata(new LinkedHashMap<>());
18+
19+
private static final Logger LOG = LoggerFactory.getLogger(CodeMetadata.class);
20+
21+
private static final Pattern DOC_COMMENT_PATTERN = Pattern.compile("/\\*\\*([^*]|\\*(?!/))*\\*/", Pattern.DOTALL);
22+
private static final Pattern TAG_PATTERN =
23+
Pattern.compile("(?m)^\\s*\\*?\\s*@(\\w+)\\s+(.+?)(?=(?m)^\\s*\\*?\\s*@\\w+|\\*/|$)", Pattern.DOTALL);
24+
private static final Pattern NEWLINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n[\\s\\S]*");
25+
private static final Pattern BLANK_LINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n\\s*\\n[\\s\\S]*");
26+
private static final Pattern IMPORT_OR_PACKAGE_BEFORE =
27+
Pattern.compile("[\\s\\S]*(import|package)[\\s\\S]*\\n\\s*\\n\\s*$");
28+
private static final Pattern FIRST_TAG_PATTERN = Pattern.compile("(?m)^\\s*\\*?\\s*@\\w+");
29+
private static final Pattern LEADING_ASTERISK = Pattern.compile("(?m)^\\s*\\*\\s?");
30+
private static final Pattern DOC_MARKERS = Pattern.compile("^/\\*\\*|\\*/$");
31+
32+
private Map<String, Object> values;
33+
34+
public CodeMetadata(Map<String, Object> values) {
35+
this.values = values;
36+
}
37+
38+
public static CodeMetadata of(Executable executable) {
39+
try {
40+
return parse(executable.getContent());
41+
} catch (Exception e) {
42+
LOG.warn("Cannot parse code metadata from executable '{}'!", executable.getId(), e);
43+
return EMPTY;
44+
}
45+
}
46+
47+
public static CodeMetadata parse(String code) {
48+
if (StringUtils.isNotBlank(code)) {
49+
String docComment = findFirstDocComment(code);
50+
if (docComment != null) {
51+
return new CodeMetadata(parseDocComment(docComment));
52+
}
53+
}
54+
return EMPTY;
55+
}
56+
57+
/**
58+
* Finds first JavaDoc/GroovyDoc comment that's properly separated with blank lines,
59+
* or directly attached to describeRun() method.
60+
*/
61+
private static String findFirstDocComment(String code) {
62+
Matcher matcher = DOC_COMMENT_PATTERN.matcher(code);
63+
64+
while (matcher.find()) {
65+
String comment = matcher.group();
66+
int commentStart = matcher.start();
67+
int commentEnd = matcher.end();
68+
69+
String afterComment = code.substring(commentEnd);
70+
71+
if (!NEWLINE_AFTER_COMMENT.matcher(afterComment).matches()) {
72+
continue;
73+
}
74+
75+
String trimmedAfter = afterComment.trim();
76+
77+
if (trimmedAfter.startsWith("void describeRun()")) {
78+
return comment;
79+
}
80+
81+
if (!BLANK_LINE_AFTER_COMMENT.matcher(afterComment).matches()) {
82+
continue;
83+
}
84+
85+
if (commentStart > 0) {
86+
String beforeComment = code.substring(0, commentStart);
87+
String trimmedBefore = beforeComment.trim();
88+
if (trimmedBefore.isEmpty()
89+
|| IMPORT_OR_PACKAGE_BEFORE.matcher(beforeComment).matches()) {
90+
return comment;
91+
}
92+
} else {
93+
return comment;
94+
}
95+
}
96+
97+
return null;
98+
}
99+
100+
/**
101+
* Extracts description and @tags from doc comment. Supports multiple values per tag.
102+
*/
103+
private static Map<String, Object> parseDocComment(String docComment) {
104+
Map<String, Object> result = new LinkedHashMap<>();
105+
106+
String content = DOC_MARKERS.matcher(docComment).replaceAll("");
107+
108+
// @ at line start (not in email addresses)
109+
Matcher firstTagMatcher = FIRST_TAG_PATTERN.matcher(content);
110+
111+
if (firstTagMatcher.find()) {
112+
int firstTagIndex = firstTagMatcher.start();
113+
String description = LEADING_ASTERISK
114+
.matcher(content.substring(0, firstTagIndex))
115+
.replaceAll("")
116+
.trim();
117+
if (!description.isEmpty()) {
118+
result.put("description", description);
119+
}
120+
} else {
121+
String description =
122+
LEADING_ASTERISK.matcher(content).replaceAll("").trim();
123+
if (!description.isEmpty()) {
124+
result.put("description", description);
125+
}
126+
}
127+
128+
Matcher tagMatcher = TAG_PATTERN.matcher(content);
129+
130+
while (tagMatcher.find()) {
131+
String tagName = tagMatcher.group(1);
132+
String tagValue = tagMatcher.group(2);
133+
134+
if (tagValue != null && !tagValue.isEmpty()) {
135+
tagValue = LEADING_ASTERISK.matcher(tagValue).replaceAll("").trim();
136+
137+
if (!tagValue.isEmpty()) {
138+
Object existing = result.get(tagName);
139+
140+
if (existing == null) {
141+
result.put(tagName, tagValue);
142+
} else if (existing instanceof List) {
143+
@SuppressWarnings("unchecked")
144+
List<String> list = (List<String>) existing;
145+
list.add(tagValue);
146+
} else {
147+
List<String> list = new ArrayList<>();
148+
list.add((String) existing);
149+
list.add(tagValue);
150+
result.put(tagName, list);
151+
}
152+
}
153+
}
154+
}
155+
return result;
156+
}
157+
158+
@JsonAnyGetter
159+
public Map<String, Object> getValues() {
160+
return values;
161+
}
162+
}

core/src/main/java/dev/vml/es/acm/core/code/Executable.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package dev.vml.es.acm.core.code;
22

3-
import dev.vml.es.acm.core.AcmException;
43
import java.io.Serializable;
54

65
public interface Executable extends Serializable {
@@ -11,5 +10,7 @@ public interface Executable extends Serializable {
1110

1211
String getId();
1312

14-
String getContent() throws AcmException;
13+
String getContent();
14+
15+
CodeMetadata getMetadata();
1516
}

core/src/main/java/dev/vml/es/acm/core/script/Script.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.fasterxml.jackson.annotation.JsonIgnore;
44
import dev.vml.es.acm.core.AcmException;
5+
import dev.vml.es.acm.core.code.CodeMetadata;
56
import dev.vml.es.acm.core.code.Executable;
67
import java.io.IOException;
78
import java.io.InputStream;
@@ -55,6 +56,11 @@ public String getContent() throws AcmException {
5556
}
5657
}
5758

59+
@Override
60+
public CodeMetadata getMetadata() {
61+
return CodeMetadata.of(this);
62+
}
63+
5864
@JsonIgnore
5965
public String getPath() {
6066
return resource.getPath();
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package dev.vml.es.acm.core.code;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.io.IOException;
6+
import java.nio.charset.StandardCharsets;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.nio.file.Paths;
10+
import java.util.List;
11+
import org.junit.jupiter.api.Test;
12+
13+
class CodeMetadataTest {
14+
15+
private static final Path SCRIPTS_BASE_PATH =
16+
Paths.get("../ui.content.example/src/main/content/jcr_root/conf/acm/settings/script");
17+
18+
private String readScript(String relativePath) throws IOException {
19+
Path scriptPath = SCRIPTS_BASE_PATH.resolve(relativePath);
20+
return new String(Files.readAllBytes(scriptPath), StandardCharsets.UTF_8);
21+
}
22+
23+
@Test
24+
void shouldParseEmptyCode() {
25+
CodeMetadata metadata = CodeMetadata.parse("");
26+
27+
assertTrue(metadata.getValues().isEmpty());
28+
}
29+
30+
@Test
31+
void shouldParseNullCode() {
32+
CodeMetadata metadata = CodeMetadata.parse(null);
33+
34+
assertTrue(metadata.getValues().isEmpty());
35+
}
36+
37+
@Test
38+
void shouldParseHelloWorldScript() throws IOException {
39+
String code = readScript("manual/example/ACME-200_hello-world.groovy");
40+
CodeMetadata metadata = CodeMetadata.parse(code);
41+
42+
assertEquals(
43+
"Prints \"Hello World!\" to the console.", metadata.getValues().get("description"));
44+
}
45+
46+
@Test
47+
void shouldParseInputsScript() throws IOException {
48+
String code = readScript("manual/example/ACME-201_inputs.groovy");
49+
CodeMetadata metadata = CodeMetadata.parse(code);
50+
51+
String description = (String) metadata.getValues().get("description");
52+
assertNotNull(description);
53+
assertTrue(description.contains("Prints animal information to the console based on user input"));
54+
assertEquals("<[email protected]>", metadata.getValues().get("author"));
55+
}
56+
57+
@Test
58+
void shouldParsePageThumbnailScript() throws IOException {
59+
String code = readScript("manual/example/ACME-202_page-thumbnail.groovy");
60+
CodeMetadata metadata = CodeMetadata.parse(code);
61+
String description = (String) metadata.getValues().get("description");
62+
63+
assertNotNull(description);
64+
assertTrue(description.contains("Updates the thumbnail"));
65+
assertTrue(description.contains("File must be a JPEG image"));
66+
assertEquals("<[email protected]>", metadata.getValues().get("author"));
67+
}
68+
69+
@Test
70+
void shouldParseScriptWithoutDocComment() throws IOException {
71+
String code = readScript("automatic/example/ACME-20_once.groovy");
72+
CodeMetadata metadata = CodeMetadata.parse(code);
73+
74+
assertTrue(metadata.getValues().isEmpty());
75+
}
76+
77+
@Test
78+
void shouldParseMultipleAuthors() {
79+
String code = "/**\n" + " * @author John Doe\n"
80+
+ " * @author Jane Smith\n"
81+
+ " */\n"
82+
+ "\n"
83+
+ "void doRun() {\n"
84+
+ " println \"Hello\"\n"
85+
+ "}";
86+
CodeMetadata metadata = CodeMetadata.parse(code);
87+
Object authors = metadata.getValues().get("author");
88+
assertTrue(authors instanceof List);
89+
@SuppressWarnings("unchecked")
90+
List<String> authorsList = (List<String>) authors;
91+
92+
assertEquals(2, authorsList.size());
93+
assertEquals("John Doe", authorsList.get(0));
94+
assertEquals("Jane Smith", authorsList.get(1));
95+
}
96+
97+
@Test
98+
void shouldParseCustomTags() {
99+
String code = "/**\n" + " * @description Custom script with metadata\n"
100+
+ " * @version 1.0.0\n"
101+
+ " * @since 2025-01-01\n"
102+
+ " * @category migration\n"
103+
+ " */\n"
104+
+ "\n"
105+
+ "void doRun() {\n"
106+
+ " println \"Hello\"\n"
107+
+ "}";
108+
109+
CodeMetadata metadata = CodeMetadata.parse(code);
110+
111+
assertEquals("Custom script with metadata", metadata.getValues().get("description"));
112+
assertEquals("1.0.0", metadata.getValues().get("version"));
113+
assertEquals("2025-01-01", metadata.getValues().get("since"));
114+
assertEquals("migration", metadata.getValues().get("category"));
115+
}
116+
}

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* This script creates content author groups for each tenant-country-language combination.
3+
*
4+
* The groups are named in the format: `{tenant}-{country}-{language}-content-authors`.
5+
* Each group is granted read, write, and replicate permissions on the corresponding content and DAM paths.
6+
*/
7+
18
def scheduleRun() {
29
return schedules.cron("0 10 * ? * * *") // every hour at minute 10
310
}

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_jms.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* - print the list (for debugging purposes),
77
* - save it directly in the repository in expected path.
88
*
9-
* @author Krystian Panek <krystian.panek@vml.com>
9+
* @author <john.doe@acme.com>
1010
*/
1111

1212
import dev.vml.es.acm.core.assist.JavaDictionary

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_rtjar.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* - print the list (for debugging purposes),
77
* - save it directly in the repository in expected path.
88
*
9-
* @author Krystian Panek <krystian.panek@vml.com>
9+
* @author <john.doe@acme.com>
1010
*/
1111

1212
import dev.vml.es.acm.core.assist.JavaDictionary

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-200_hello-world.groovy

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
/**
2-
* Prints "Hello World!" to the console.
2+
* Prints "Hello World!" to the console.
33
*
4-
* This is a minimal example of AEM Content Manager script.
5-
*
6-
* @author Krystian Panek <[email protected]>
4+
* @author <[email protected]>
75
*/
86

97
boolean canRun() {

ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-201_inputs.groovy

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
/**
22
* Prints animal information to the console based on user input.
3-
*
4-
* This is an example of AEM Content Manager script with inputs.
5-
*
6-
* @author Krystian Panek <[email protected]>
3+
*
4+
* @author <[email protected]>
75
*/
8-
96
void describeRun() {
107
inputs.string("animalName") { value = "Whiskers";
118
validator = "(v, a) => a.animalType === 'cat' ? (v && v.startsWith('W') || 'Cat name must start with W!') : true" }

0 commit comments

Comments
 (0)