/*
* The MIT License
*
* Copyright 2014 Jesse Glick.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.jenkinsci.plugins.workflow.cps;
import hudson.Extension;
import hudson.Functions;
import hudson.model.Action;
import hudson.model.Describable;
import hudson.model.Descriptor;
import hudson.model.DescriptorByNameOwner;
import hudson.model.Item;
import hudson.model.Job;
import hudson.model.RootAction;
import hudson.tasks.BuildStepDescriptor;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.CheckForNull;
import javax.lang.model.SourceVersion;
import jenkins.model.Jenkins;
import jenkins.model.TransientActionFactory;
import jenkins.tasks.SimpleBuildStep;
import net.sf.json.JSONObject;
import org.jenkinsci.Symbol;
import org.jenkinsci.plugins.structs.SymbolLookup;
import org.jenkinsci.plugins.structs.describable.DescribableModel;
import org.jenkinsci.plugins.structs.describable.DescribableParameter;
import org.jenkinsci.plugins.structs.describable.HeterogeneousObjectType;
import org.jenkinsci.plugins.structs.describable.UninstantiatedDescribable;
import org.jenkinsci.plugins.workflow.steps.Step;
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest;
/**
* Takes a {@link Step} as configured through the UI and tries to produce equivalent Groovy code.
*/
@Extension public class Snippetizer implements RootAction, DescriptorByNameOwner {
/**
* Short-hand for the top-level invocation.
*/
static String step2Groovy(Step s) throws UnsupportedOperationException {
return object2Groovy(new StringBuilder(), s, false).toString();
}
/**
* Renders the invocation syntax to re-create a given object 'o' into 'b'
*
* @param nestedExp
* true if this object is written as a nested expression (in which case we always produce parenthesis for readability)
* @return the same object as 'b'
*/
static StringBuilder object2Groovy(StringBuilder b, Object o, boolean nestedExp) throws UnsupportedOperationException {
if (o == null) {
return b.append("null");
}
final Class<?> clazz = o.getClass();
if (clazz == String.class || clazz == Character.class) {
String text = String.valueOf(o);
if (text.contains("\n")) {
b.append("'''").append(text.replace("\\", "\\\\").replace("'", "\\'")).append("'''");
} else {
b.append('\'').append(text.replace("\\", "\\\\").replace("'", "\\'")).append('\'');
}
return b;
}
if (clazz == Boolean.class || clazz == Integer.class || clazz == Long.class) {
return b.append(o);
}
if (o instanceof List) {
return list2groovy(b, (List<?>) o);
}
if (o instanceof Map) {
return map2groovy(b, (Map) o);
}
if (o instanceof UninstantiatedDescribable) {
return ud2groovy(b,(UninstantiatedDescribable)o, false, nestedExp);
}
for (StepDescriptor d : StepDescriptor.all()) {
if (d.clazz.equals(clazz)) {
Step step = (Step) o;
UninstantiatedDescribable uninst = d.uninstantiate(step);
boolean blockArgument = d.takesImplicitBlockArgument();
if (d.isMetaStep()) {
// if we have a symbol name for the wrapped Describable, we can produce
// a more concise form that hides it
DescribableModel<?> m = new DescribableModel(d.clazz);
DescribableParameter p = m.getFirstRequiredParameter();
if (p!=null) {
Object wrapped = uninst.getArguments().get(p.getName());
if (wrapped instanceof UninstantiatedDescribable) {
// if we cannot represent this 'o' in a concise syntax that hides meta-step, set this to true
boolean failSimplification = false;
UninstantiatedDescribable nested = (UninstantiatedDescribable) wrapped;
TreeMap<String, Object> copy = new TreeMap<String, Object>(nested.getArguments());
for (Entry<String, ?> e : uninst.getArguments().entrySet()) {
if (!e.getKey().equals(p.getName())) {
if (copy.put(e.getKey(), e.getValue()) != null) {
// collision between a parameter in meta-step and wrapped-step,
// which cannot be reconciled unless we explicitly write out
// meta-step
failSimplification = true;
}
}
}
if (!canUseMetaStep(nested))
failSimplification = true;
if (!failSimplification) {
// write out in a short-form
UninstantiatedDescribable combined = new UninstantiatedDescribable(
nested.getSymbol(), nested.getKlass(), copy);
combined.setModel(nested.getModel());
return ud2groovy(b, combined, blockArgument, nestedExp);
}
}
} else {
// this can only happen with buggy meta-step
LOGGER.log(Level.WARNING, "Buggy meta-step "+d.clazz+" defines no mandatory parameter");
// use the default code path to write it out as: metaStep(describable(...))
}
}
uninst.setSymbol(d.getFunctionName());
return functionCall(b, uninst, blockArgument, nestedExp);
}
}
// unknown type
return b.append("<object of type ").append(clazz.getCanonicalName()).append('>');
}
/**
* Can this symbol name be used to produce a short hand?
*/
private static boolean canUseMetaStep(UninstantiatedDescribable ud) {
return canUseSymbol(ud) && StepDescriptor.metaStepsOf(ud.getSymbol()).size()==1;
}
private static StringBuilder list2groovy(StringBuilder b, List<?> o) {
b.append('[');
boolean first = true;
for (Object elt : o) {
if (first) {
first = false;
} else {
b.append(", ");
}
object2Groovy(b, elt, true);
}
return b.append(']');
}
private static StringBuilder map2groovy(StringBuilder b, Map<?,?> map) {
b.append('[');
mapWithoutBracket2groovy(b, map);
return b.append(']');
}
private static void mapWithoutBracket2groovy(StringBuilder b, Map<?, ?> map) {
boolean first = true;
for (Entry<?, ?> entry : map.entrySet()) {
if (first) {
first = false;
} else {
b.append(", ");
}
Object key = entry.getKey();
if (key instanceof String && SourceVersion.isName((String) key)) {
b.append(key);
} else {
object2Groovy(b, key, true);
}
b.append(": ");
object2Groovy(b, entry.getValue(), true);
}
}
/**
* Writes out a snippet that instantiates {@link UninstantiatedDescribable}
*
* @param nested
* true if this object is written as a nested expression (in which case we always produce parenthesis for readability
*/
private static StringBuilder ud2groovy(StringBuilder b, UninstantiatedDescribable ud, boolean blockArgument, boolean nested) {
if (!canUseSymbol(ud)) {
// if there's no symbol, we need to write this as [$class:...]
return map2groovy(b, ud.toShallowMap());
}
return functionCall(b, ud, blockArgument, nested);
}
private static boolean canUseSymbol(UninstantiatedDescribable ud) {
if (ud.getSymbol() == null) {
// if there's no symbol, we need to write this as [$class:...]
return false;
}
if (StepDescriptor.byFunctionName(ud.getSymbol()) != null) {
// if the symbol collides with existing step name, then we cannot use it
return false;
}
return true;
}
/**
* Writes out a given {@link UninstantiatedDescribable} as a function call form.
*
* @param nested
* true if this object is written as a nested expression (in which case we always produce parenthesis for readability
*/
private static StringBuilder functionCall(StringBuilder b, UninstantiatedDescribable ud, boolean blockArgument, boolean nested) {
Map<String, ?> args = ud.getArguments();
// if the whole argument is just one map?
// the call needs explicit parenthesis sometimes
// a block argument normally requires a () around arguments, and if arguments are empty you need explicit (),
// but not if both is the case!
final boolean needParenthesis = (blockArgument ^ args.isEmpty()) || isSingleMap(args) || isSingleList(args) || nested;
b.append(ud.getSymbol());
b.append(needParenthesis ? '(': ' ');
if (ud.hasSoleRequiredArgument()) {
// lone argument optimization, which gets rid of named arguments and just write one value, like
// retry (5) { ... }
object2Groovy(b, args.values().iterator().next(), true);
} else {
// usual form, which calls out argument names, like
// git url:'...', browser:'...'
mapWithoutBracket2groovy(b,args);
}
if (needParenthesis)
b.append(')');
if (blockArgument) {
if (!args.isEmpty()) b.append(' ');
b.append("{\n // some block\n}");
}
return b;
}
/**
* If the sole argument is a map, its [...] bracket cannot be present.
*
* Historically we've disambiguated this by adding (...) around the function call.
* TODO: I claim removing both () and [] would be better.
*
% groovysh
Groovy Shell (2.0.2, JVM: 1.7.0_07)
Type 'help' or '\h' for help.
---------------------------------------------------------------------------------------------------------------------------------------------
groovy:000> def foo(o) { println o }
===> true
groovy:000> foo abc:1, def:2
[abc:1, def:2]
===> null
groovy:000> foo(abc:1, def:2)
[abc:1, def:2]
===> null
groovy:000> foo [abc:1,def:2]
ERROR org.codehaus.groovy.control.MultipleCompilationErrorsException:
startup failed:
groovysh_evaluate: 2: No map entry allowed at this place
. At [2:9] @ line 2, column 9.
foo [abc:1,def:2]
^
1 error
at java_lang_Runnable$run.call (Unknown Source)
groovy:000> foo([abc:1,def:2])
[abc:1, def:2]
===> null
*/
private static boolean isSingleMap(Map<String, ?> args) {
if (args.size()!=1) return false;
Object v = args.values().iterator().next();
if (v instanceof Map)
return true;
if (v instanceof UninstantiatedDescribable) {
// UninstantiatedDescribable can be written out as a Map so treat it as a map
return !canUseSymbol((UninstantiatedDescribable)v);
}
return false;
}
/**
* If the single argument is a list, it must be wrapped in parentheses.
*
* @param args
* Argument map
* @return
* True if there's only one argument and it's a list, false otherwise.
*/
private static boolean isSingleList(Map<String, ?> args) {
if (args.size()!=1) return false;
Object v = args.values().iterator().next();
return v instanceof List;
}
public static final String ACTION_URL = "pipeline-syntax";
@Override public String getUrlName() {
return ACTION_URL;
}
@Override public String getIconFileName() {
return null;
}
@Override public String getDisplayName() {
// Do not want to add to main Jenkins sidepanel.
return null;
}
@Override public Descriptor getDescriptorByName(String id) {
return Jenkins.getActiveInstance().getDescriptorByName(id);
}
@Restricted(NoExternalUse.class)
public Collection<QuasiDescriptor> getQuasiDescriptors(boolean advanced) {
TreeSet<QuasiDescriptor> t = new TreeSet<>();
for (StepDescriptor d : StepDescriptor.all()) {
if (d.isAdvanced() == advanced) {
t.add(new QuasiDescriptor(d));
if (d.isMetaStep()) {
DescribableModel<?> m = new DescribableModel<>(d.clazz);
Collection<DescribableParameter> parameters = m.getParameters();
if (parameters.size() == 1) {
DescribableParameter delegate = parameters.iterator().next();
if (delegate.isRequired()) {
if (delegate.getType() instanceof HeterogeneousObjectType) {
// TODO HeterogeneousObjectType does not yet expose symbol information, and DescribableModel.symbolOf is private
for (DescribableModel<?> delegateOptionSchema : ((HeterogeneousObjectType) delegate.getType()).getTypes().values()) {
Class<?> delegateOptionType = delegateOptionSchema.getType();
Descriptor<?> delegateDescriptor = Jenkins.getActiveInstance().getDescriptorOrDie(delegateOptionType.asSubclass(Describable.class));
Set<String> symbols = SymbolLookup.getSymbolValue(delegateDescriptor);
if (!symbols.isEmpty()) {
t.add(new QuasiDescriptor(delegateDescriptor));
}
}
}
}
} // TODO currently not handling metasteps with other parameters, either required or (like GenericSCMStep) not
}
}
}
return t;
}
/**
* Represents a step or other step-like objects that should appear in {@link Snippetizer}’s main dropdown list
* and can generate some fragment of Pipeline script.
* {@link #real} can be a {@link StepDescriptor}, in which case we generate an invocation of that step.
* Or it can be any {@link Descriptor} that can be run by a {@linkplain StepDescriptor#isMetaStep meta step},
* such as a {@link BuildStepDescriptor} of a {@link SimpleBuildStep} (from {@code CoreStep}) with a {@link Symbol},
* because from the user point of view a regular {@link Describable} run via a metastep
* is syntactically indistinguishable from a true {@link Step}.
*/
@Restricted(NoExternalUse.class)
public static final class QuasiDescriptor implements Comparable<QuasiDescriptor> {
public final Descriptor<?> real;
QuasiDescriptor(Descriptor<?> real) {
this.real = real;
}
public String getSymbol() {
if (real instanceof StepDescriptor) {
return ((StepDescriptor) real).getFunctionName();
} else {
Set<String> symbolValues = SymbolLookup.getSymbolValue(real);
if (!symbolValues.isEmpty()) {
return symbolValues.iterator().next();
} else {
throw new AssertionError("Symbol present but no values defined.");
}
}
}
@Override public int compareTo(QuasiDescriptor o) {
return getSymbol().compareTo(o.getSymbol());
}
@Override public boolean equals(Object obj) {
return obj instanceof QuasiDescriptor && real == ((QuasiDescriptor) obj).real;
}
@Override public int hashCode() {
return real.hashCode();
}
@Override public String toString() {
return getSymbol() + "=" + real.clazz.getSimpleName();
}
}
@Restricted(DoNotUse.class) // for stapler
public Iterable<GlobalVariable> getGlobalVariables() {
// TODO order TBD. Alphabetical? Extension.ordinal?
StaplerRequest req = Stapler.getCurrentRequest();
return GlobalVariable.forJob(req != null ? req.findAncestorObject(Job.class) : null);
}
@Restricted(NoExternalUse.class)
public static final String GENERATE_URL = ACTION_URL + "/generateSnippet";
@Restricted(DoNotUse.class) // accessed via REST API
public HttpResponse doGenerateSnippet(StaplerRequest req, @QueryParameter String json) throws Exception {
// TODO is there not an easier way to do this? Maybe Descriptor.newInstancesFromHeteroList on a one-element JSONArray?
JSONObject jsonO = JSONObject.fromObject(json);
Jenkins j = Jenkins.getActiveInstance();
Class<?> c = j.getPluginManager().uberClassLoader.loadClass(jsonO.getString("stapler-class"));
Descriptor descriptor = j.getDescriptor(c.asSubclass(Describable.class));
if (descriptor == null) {
return HttpResponses.plainText("<could not find " + c.getName() + ">");
}
Object o;
try {
o = descriptor.newInstance(req, jsonO);
} catch (RuntimeException x) { // e.g. IllegalArgumentException
return HttpResponses.plainText(Functions.printThrowable(x));
}
try {
Step step = null;
if (o instanceof Step) {
step = (Step) o;
} else {
// Look for a metastep which could take this as its delegate.
for (StepDescriptor d : StepDescriptor.allMeta()) {
if (d.getMetaStepArgumentType().isInstance(o)) {
DescribableModel<?> m = new DescribableModel<>(d.clazz);
DescribableParameter soleRequiredParameter = m.getSoleRequiredParameter();
if (soleRequiredParameter != null) {
step = d.newInstance(Collections.singletonMap(soleRequiredParameter.getName(), o));
break;
}
}
}
}
if (step == null) {
return HttpResponses.plainText("Cannot find a step corresponding to " + o.getClass().getName());
}
String groovy = step2Groovy(step);
if (descriptor instanceof StepDescriptor && ((StepDescriptor) descriptor).isAdvanced()) {
String warning = Messages.Snippetizer_this_step_should_not_normally_be_used_in();
groovy = "// " + warning + "\n" + groovy;
}
return HttpResponses.plainText(groovy);
} catch (UnsupportedOperationException x) {
Logger.getLogger(CpsFlowExecution.class.getName()).log(Level.WARNING, "failed to render " + json, x);
return HttpResponses.plainText(x.getMessage());
}
}
@Restricted(DoNotUse.class) // for stapler
public @CheckForNull Item getItem(StaplerRequest req) {
return req.findAncestorObject(Item.class);
}
@Restricted(DoNotUse.class)
@Extension public static class PerJobAdder extends TransientActionFactory<Job> {
@Override public Class<Job> type() {
return Job.class;
}
@Override public Collection<? extends Action> createFor(Job target) {
// TODO probably want an API for FlowExecutionContainer or something
if (target.getClass().getName().equals("org.jenkinsci.plugins.workflow.job.WorkflowJob") && target.hasPermission(Item.EXTENDED_READ)) {
return Collections.singleton(new LocalAction());
} else {
return Collections.emptySet();
}
}
}
/**
* May be added to various contexts to offer the Pipeline Groovy link where it is appropriate.
* To use, define a {@link TransientActionFactory} of some kind of {@link Item}.
* If the target {@link Item#hasPermission} {@link Item#EXTENDED_READ},
* return one {@link LocalAction}. Otherwise return an empty set.
*/
public static class LocalAction extends Snippetizer {
@Override public String getDisplayName() {
return "Pipeline Syntax";
}
public String getIconClassName() {
return "icon-help";
}
}
private static final Logger LOGGER = Logger.getLogger(Snippetizer.class.getName());
}