Skip to content

Commit

Permalink
Add Jenkins Symbols autocomplete (#195)
Browse files Browse the repository at this point in the history
Co-authored-by: Denys Digtiar <[email protected]>
  • Loading branch information
janfaracik and duemir authored Dec 28, 2024
1 parent 0b39014 commit e935a1b
Show file tree
Hide file tree
Showing 18 changed files with 665 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Features include:
* Report custom tag attributes that are marked deprecated in `st:documentation`.
* Error checks and autocompletion on attributes and elements of taglibs
* `style` attribute and `script` tag contents should be recognized as CSS and JavaScript correspondingly.
* [Jenkins Symbols](https://weekly.ci.jenkins.io/design-library/symbols/) suggestions are available when using `<l:icon src="..." />`

## JEXL

Expand Down
12 changes: 11 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = "org.kohsuke.stapler.idea"
version = "3.0.5"
version = "3.0.6"

java {
toolchain {
Expand All @@ -30,6 +30,12 @@ dependencies {
}
implementation 'net.java.dev.textile-j:textile-j:2.2.864'
implementation 'org.apache.commons:commons-text:1.13.0'
implementation('io.jenkins.plugins:ionicons-api:74.v93d5eb_813d5f') {
artifact {
name = "ionicons-api"
type = "jar" // Force Gradle to use the JAR artifact
}
}

testImplementation 'junit:junit:4.13.2'
}
Expand Down Expand Up @@ -63,6 +69,10 @@ patchPluginXml {
untilBuild = ""
pluginDescription = extractPluginDescription()
changeNotes = """
<h3>3.0.6</h3>
<ul>
<li>🚀 Jenkins Symbols suggestions are now available when using the icon component</li>
</ul>
<h3>3.0.5</h3>
<ul>
<li>🚀 Use JetBrains Marketplace exception analyzer</li>
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ ideaVersion=2023.3
sinceIdeaVersion=
# see https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type for list of accepted types
ideaType=IC
platformPlugins=properties,java
platformPlugins=properties,java,platform-images
intellijPublishToken=
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.jenkins.stapler.idea.jelly;

import com.intellij.codeInsight.completion.CompletionContributor;
import com.intellij.codeInsight.completion.CompletionParameters;
import com.intellij.codeInsight.completion.CompletionProvider;
import com.intellij.codeInsight.completion.CompletionResultSet;
import com.intellij.codeInsight.completion.CompletionType;
import com.intellij.codeInsight.lookup.LookupElementBuilder;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.openapi.project.Project;
import com.intellij.patterns.PlatformPatterns;
import com.intellij.psi.PsiElement;
import com.intellij.psi.impl.source.xml.XmlAttributeImpl;
import com.intellij.psi.util.PsiTreeUtil;
import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlTag;
import com.intellij.util.ProcessingContext;
import io.jenkins.stapler.idea.jelly.symbols.SymbolFinder;
import org.jetbrains.annotations.NotNull;

public class IconSrcCompletionContributor extends CompletionContributor {

public IconSrcCompletionContributor() {
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement().inside(XmlAttributeImpl.class),
new CompletionProvider<>() {
@Override
protected void addCompletions(
@NotNull CompletionParameters parameters,
@NotNull ProcessingContext context,
@NotNull CompletionResultSet result) {
PsiElement position = parameters.getPosition();
PsiElement parent = position.getParent().getParent();

if (isInsideLIconSrcAttribute(parent)) {
Project project = position.getProject();
SymbolFinder.getInstance(project)
.getAvailableSymbols()
.forEach(symbol -> result.addElement(LookupElementBuilder.create(symbol.name())
.withPresentableText(symbol.displayText())
.withTypeText(symbol.group())
.withInsertHandler((insertionContext, item) -> {
XmlAttribute attribute = PsiTreeUtil.getParentOfType(
insertionContext
.getFile()
.findElementAt(insertionContext.getStartOffset()),
XmlAttribute.class);
if (attribute != null) {
WriteCommandAction.runWriteCommandAction(
project, () -> attribute.setValue(symbol.name()));
}
})));
}
}
});
}

private boolean isInsideLIconSrcAttribute(PsiElement element) {
if (element instanceof XmlAttributeImpl attribute) {
if ("src".equals(attribute.getName())) {
PsiElement parent = attribute.getParent();
if (parent instanceof XmlTag xmlTag) {
return "icon".equals(xmlTag.getLocalName()) && "/lib/layout".equals(xmlTag.getNamespace());
}
}
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.jenkins.stapler.idea.jelly;

import static io.jenkins.stapler.idea.jelly.symbols.ClosestStringFinder.findClosestString;

import com.intellij.codeInspection.LocalInspectionTool;
import com.intellij.codeInspection.LocalQuickFix;
import com.intellij.codeInspection.ProblemDescriptor;
import com.intellij.codeInspection.ProblemsHolder;
import com.intellij.codeInspection.util.IntentionFamilyName;
import com.intellij.openapi.project.Project;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.XmlElementVisitor;
import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlTag;
import io.jenkins.stapler.idea.jelly.symbols.Symbol;
import io.jenkins.stapler.idea.jelly.symbols.SymbolFinder;
import java.util.Set;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;

public class InvalidIconSrcInspection extends LocalInspectionTool {

@Override
public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) {
return new XmlElementVisitor() {
@Override
public void visitXmlAttribute(@NotNull XmlAttribute attribute) {
if (validAttributeToScan(attribute)) {
Set<String> symbols =
SymbolFinder.getInstance(attribute.getProject()).getAvailableSymbols().stream()
.map(Symbol::name)
.collect(Collectors.toSet());
if (!symbols.contains(attribute.getValue())) {
String closestSymbol = findClosestString(attribute.getValue(), symbols);

if (closestSymbol == null) {
holder.registerProblem(
attribute.getValueElement(),
String.format("'%s' isn't a valid symbol", attribute.getValue()));
} else {
holder.registerProblem(
attribute.getValueElement(),
String.format("'%s' isn't a valid symbol", attribute.getValue()),
new LocalQuickFix() {
@Override
public @IntentionFamilyName @NotNull String getFamilyName() {
return "Replace with '" + closestSymbol + "'";
}

@Override
public void applyFix(
@NotNull Project project, @NotNull ProblemDescriptor descriptor) {
PsiElement element = descriptor.getPsiElement();
if (element != null
&& element.getParent() instanceof XmlAttribute attribute) {
attribute.setValue(closestSymbol);
}
}
});
}
}
}
}
};
}

private boolean validAttributeToScan(@NotNull XmlAttribute attribute) {
if ("src".equals(attribute.getName())) {
PsiElement parent = attribute.getParent();
if (parent instanceof XmlTag xmlTag) {
String attributeValue = attribute.getValue();
if (attributeValue == null) {
return false;
}
return "icon".equals(xmlTag.getLocalName())
&& "/lib/layout".equals(xmlTag.getNamespace())
&& attribute.getValue().startsWith("symbol-")
&& !attribute.getValue().contains("${"); // In some cases symbols are dynamically generated
}
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.jenkins.stapler.idea.jelly.symbols;

import java.util.Set;

public final class ClosestStringFinder {

/**
* Finds the closest string in the set to the target string.
*
* @param target The string to compare against.
* @param set The set of strings to search.
* @return The closest string, or null if the set is empty.
*/
public static String findClosestString(String target, Set<String> set) {
if (set == null || set.isEmpty()) {
return null;
}

// Extract the portion before the first space in the target string
String targetPrefix = target.split(" ")[0];

String closestString = null;
int smallestDistance = 5;

for (String candidate : set) {
// Extract the portion before the first space in the candidate string
String candidatePrefix = candidate.split(" ")[0];

// Calculate Levenshtein distance on the prefixes
int distance = levenshteinDistance(targetPrefix, candidatePrefix);

// Update the closest string if a smaller distance is found
if (distance < smallestDistance) {
smallestDistance = distance;
closestString = candidate; // Store the full candidate string
}
}

return closestString;
}

private static int levenshteinDistance(String s1, String s2) {
int[][] dp = new int[s1.length() + 1][s2.length() + 1];

for (int i = 0; i <= s1.length(); i++) {
for (int j = 0; j <= s2.length(); j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else {
dp[i][j] = Math.min(
dp[i - 1][j - 1] + (s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1),
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1));
}
}
}

return dp[s1.length()][s2.length()];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.jenkins.stapler.idea.jelly.symbols;

import static org.kohsuke.stapler.idea.MavenProjectHelper.hasDependency;

import com.intellij.openapi.project.Project;
import io.jenkins.plugins.ionicons.Ionicons;
import java.util.Set;
import java.util.stream.Collectors;

public class IoniconsApiSymbolLookup implements SymbolLookup {

private static final String PREFIX = "symbol-";
private static final String SUFFIX = " plugin-ionicons-api";

/** Adds the Ionicons API symbols if the plugin has the dependency added */
@Override
public Set<Symbol> getSymbols(Project project) {
if (!hasDependency(project, "io.jenkins.plugins", "ionicons-api")) {
return Set.of();
}

return Ionicons.getAvailableIcons().keySet().stream()
.map(e -> new Symbol(PREFIX + e + SUFFIX, PREFIX + e, "plugin-ionicons-api"))
.collect(Collectors.toSet());
}
}
Loading

0 comments on commit e935a1b

Please sign in to comment.