Groovy (Jenkins, Gradle and Spock) Module 2 – Core syntax: values, strings, operators and control flow
Strings, GStrings and multi-line text
The six Groovy string literals, when a GString reads ${} placeholders, why it misses as a map key, the GDK string methods, and sh steps in Jenkins.
What you will learn
- Choose among single-quoted, double-quoted, triple-quoted, slashy and dollar-slashy strings for a given text
- Predict when a GString reads its placeholders, and delay the reading with ${-> }
- Explain why a GString key misses a String lookup in a map, set or list, and fix it with toString()
- Use the GDK string methods for padding, splitting, repeating and stripping indentation
- Recognise Groovy interpolation in a Jenkins sh step and keep parameters and secrets out of it
Before you start
What you’ll learn · Before you start
What you will learn
- Choose among single-quoted, double-quoted, triple-quoted, slashy and dollar-slashy strings for a given text
- Predict when a GString reads its placeholders, and delay the reading with ${-> }
- Explain why a GString key misses a String lookup in a map, set or list, and fix it with toString()
- Use the GDK string methods for padding, splitting, repeating and stripping indentation
- Recognise Groovy interpolation in a Jenkins sh step and keep parameters and secrets out of it
Before you start
On this page
"api-${env}" == 'api-prod' is true in Groovy when env is 'prod', yet a map whose key was written
"api-${env}" returns null for tags['api-prod']. The double-quoted string is a GString, a different class from
String, and that difference decides map lookups, the moment a value is read and what a Jenkins sh step runs.
Groovy has six ways to write a string literal, and four of them fill in placeholders. You will pick the right one for
paths, patterns and multi-line text, predict when ${} is evaluated, make GString keys find their entries, use the
string methods that Groovy adds to Java, and keep build parameters out of shell commands.
Six string literals, and four of them fill in placeholders
Single quotes make a plain java.lang.String. Double quotes make a GString: groovy.lang.GString, the class of a double-quoted string that holds a placeholder such as ${name}. It keeps its text and its values apart and joins them when it is used as text, so it equals a String under == but not under equals. as soon as the
text holds a placeholder such as ${name}; filling it in is called interpolation: Filling a placeholder such as ${name} in a string with the value of the expression inside it. Groovy interpolates double-quoted, triple-double-quoted, slashy and dollar-slashy strings, never single-quoted ones..
Triple quotes do the same across several lines, and the two slashy forms exist for text full of backslashes and
slashes. The documentation sums them up in a table, which this one follows:
| Literal | Fills in ${} |
Spans lines | Escape character |
|---|---|---|---|
'…' single-quoted |
no | no | \ |
'''…''' triple-single-quoted |
no | yes | \ |
"…" double-quoted |
yes | no | \ |
"""…""" triple-double-quoted |
yes | yes | \ |
/…/ slashy |
yes | yes | \, only before / |
$/…/$ dollar-slashy |
yes | yes | $ |
The program prints what each form holds and its class, then two strings that need no escaping at all:
// The six string literals of Groovy: what each one holds, and its class.
def name = 'Asha'
def forms = [
"'...'" : 'Hi ${name}',
'"..."' : "Hi ${name}",
"'''...'''" : '''Hi ${name}''',
'"""..."""' : """Hi ${name}""",
'/.../' : /Hi ${name}/,
'$/.../$' : $/Hi ${name}/$,
]
forms.each { form, text ->
println "${form.padRight(12)}${text.toString().padRight(12)}${text.getClass().simpleName}"
}
def plain = "Hi there"
println "\"Hi there\" has no placeholder, so it is a ${plain.getClass().simpleName}"
println(/In a slashy string a Windows path keeps its backslashes: C:\Users\asha/)
println($/A dollar-slashy string keeps / and \d+, and writes $$ for a dollar sign/$) from session import jenkins_jar, run, use
# The six literals on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy quotes.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy quotes.groovy > on-{version}.txt
diff on-{version}.txt on-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain quotes.groovy > on-2.4.21.txt
diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
""") Output
$ groovy quotes.groovy | tee on-6.0.0.txt
'...' Hi ${name} String
"..." Hi Asha GStringImpl
'''...''' Hi ${name} String
"""...""" Hi Asha GStringImpl
/.../ Hi Asha GStringImpl
$/.../$ Hi Asha GStringImpl
"Hi there" has no placeholder, so it is a String
In a slashy string a Windows path keeps its backslashes: C:\Users\asha
A dollar-slashy string keeps / and \d+, and writes $ for a dollar sign
# The groovy command is Groovy 5.1.3 from here on.
$ groovy quotes.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy quotes.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain quotes.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 quotes_session.py
The single-quoted forms printed ${name} exactly as typed. The other four put Asha in and are GStringImpl, Groovy’s
implementation of a GString, while "Hi there", double-quoted but without a placeholder, is an ordinary String. The
slashy string: A Groovy string written between slashes, such as /\d+/. Every backslash stays as written and only a slash needs escaping, so it suits regular expressions and Windows paths. It fills in ${} placeholders. kept every backslash of a Windows path, and $$ in the dollar-slashy
string wrote one dollar sign. The output was the same on all four runtimes.
The same path in double quotes does not compile, because \U is not an escape that Groovy knows. The message you get
depends on the runtime:
def path = "C:\Users\asha"
println path from session import jenkins_jar, run, use
# A Windows path in a double-quoted string, on the four runtimes: only the line that names the error is shown.
run("""
groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run("""
groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain escapes.groovy 2>&1 | grep 'escapes.groovy: 1:'
""") Output
$ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:' escapes.groovy: 1: Illegal escape character: '\U' @ line 1, column 15. # The groovy command is Groovy 5.1.3 from here on. $ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:' escapes.groovy: 1: Unexpected character: '"' @ line 1, column 12. # The groovy command is Groovy 4.0.33 from here on. $ groovy escapes.groovy 2>&1 | grep 'escapes.groovy: 1:' escapes.groovy: 1: Unexpected character: '"' @ line 1, column 12. # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain escapes.groovy 2>&1 | grep 'escapes.groovy: 1:' escapes.groovy: 1: unexpected char: '\' @ line 1, column 15.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 escapes_session.py
Groovy 6.0.0 names the problem, Illegal escape character: '\U'. Groovy 5.1.3 and 4.0.33 report Unexpected character: '"' at column 12, the opening quote, and Jenkins’ 2.4.21 says unexpected char: '\'. Write a Windows path
as a slashy string, or double every backslash.
A GString reads its placeholders once, when it is built
A placeholder is evaluated when the GString is created, and the GString keeps the value it got. The documentation
describes a second form, a closure: A block of code in braces that can be stored in a variable, passed to a method and called later. In Groovy, braces after = always start a closure. More in “Groovy for Java developers: what changes” expression: ${-> name} holds a small function instead
of a value, and that
function runs every time the GString becomes a String. Step through the program, then compare with its output:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- name is Asha. The script sets name to 'Asha' and builds two GStrings from it, one with a plain placeholder and one whose placeholder starts with an arrow.
- Eager keeps a value. For "Hi ${name}" Groovy evaluates the placeholder at once and keeps what it got, the String Asha, next to the text Hi.
- Lazy keeps a closure. For "Hi ${-> name}" the placeholder is a closure. Groovy keeps the closure itself, so nothing has read name yet.
- name changes. The script gives name the value 'Ravi'. The eager GString does not notice, because it never looks at the variable again.
- Printing eager. Printing eager joins its text with the value it kept, so the session printed eager, Hi Asha.
- Printing lazy. Printing lazy calls the closure, which reads name now, so the session printed lazy, Hi Ravi. Every conversion to a String calls the closure again.
- Kept once, not copied. An eager placeholder runs once, when the GString is built, so count++ ran once. It keeps the object it got, not a copy, so report showed the stages list after test was added to it.
def name = 'Asha'
def eager = "Hi ${name}"
def lazy = "Hi ${-> name}"
name = 'Ravi'
println "eager: $eager"
println "lazy: $lazy"
def count = 1
def line = "count is ${count++}"
println line
println line
println "count is now $count"
def stages = ['build']
def report = "stages: ${stages}"
stages << 'test'
println report from session import jenkins_jar, run, use
# Eager and lazy placeholders on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy lazy.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy lazy.groovy > on-{version}.txt
diff on-{version}.txt on-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain lazy.groovy > on-2.4.21.txt
diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
""") Output
$ groovy lazy.groovy | tee on-6.0.0.txt eager: Hi Asha lazy: Hi Ravi count is 1 count is 1 count is now 2 stages: [build, test] # The groovy command is Groovy 5.1.3 from here on. $ groovy lazy.groovy > on-5.1.3.txt $ diff on-5.1.3.txt on-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines' Groovy 5.1.3 printed the same lines # The groovy command is Groovy 4.0.33 from here on. $ groovy lazy.groovy > on-4.0.33.txt $ diff on-4.0.33.txt on-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines' Groovy 4.0.33 printed the same lines # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain lazy.groovy > on-2.4.21.txt $ diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines' Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy_session.py
eager printed Hi Asha after name had changed, and lazy printed Hi Ravi. count++ ran once, when line was
built, so the line said count is 1 twice and count ended at 2. The last line is the subtle one. report kept the
list stages itself, not its text, so it printed [build, test] after test was added: a placeholder fixes which
object is shown, but a mutable object can still change before the text is used.
Key idea
A placeholder runs once, when the GString is built, and keeps the object it got; ${-> expr} runs again each time
the GString is turned into text.
Checkpoint
Check: The six string literals · When a GString reads its placeholders
A GString is not a String, so map and set lookups miss
Groovy’s == compares a GString with a String by their text, which is why "api-${env}" == 'api-prod' is true. Java’s
collections do not use Groovy’s ==. A hash map files each key under its hash code: The int that hashCode() returns, which hash maps and sets use to file and find a key. Equal objects must have equal hash codes, and a GString's hash code differs from that of a String with the same text. and
compares keys with equals, and the documentation warns that a GString and a String with the same text have different
hash codes. Step through one lookup:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The entry goes in. The map literal ["api-${env}": '6.2.1'] keeps its key as a GString. A hash map files every entry under the key's hash code, here 921144143.
- The lookup. tags['api-prod'] asks with a String. Its text is the same, but a String computes its hash code another way, 921144106.
- The wrong place. The map looks for the entry under 921144106 and finds nothing there, so the lookup returns null, as the session printed.
- Equal, but not equals. Even under the same hash code the entry would not match, because a map compares keys with equals, and equals between a GString and a String is false, as key.equals('api-prod') printed. Only Groovy's == called them equal.
- The fix. Turn the key into a String before it goes in, with toString() as in the fixed map. Then both keys give 921144106 and equals is true, so tags['api-prod'] found 6.2.1.
The program builds the same key into a map four ways, and into a list and a set:
def env = 'prod'
def key = "api-${env}"
println "key == 'api-prod' ${key == 'api-prod'}"
println "key.equals('api-prod') ${key.equals('api-prod')}"
println "hash codes ${key.hashCode()} and ${'api-prod'.hashCode()}"
def tags = ["api-${env}": '6.2.1']
println "map literal: tags['api-prod'] ${tags['api-prod']}, key is a ${tags.keySet()[0].getClass().simpleName}"
println "map literal: tags[\"api-\${env}\"] ${tags["api-${env}"]}"
println "map literal: tags.get(\"api-\${env}\") ${tags.get("api-${env}")}"
def viaPut = [:]
viaPut.put("api-${env}", '6.2.1')
println "put(): ['api-prod'] ${viaPut['api-prod']}"
def viaSubscript = [:]
viaSubscript["api-${env}"] = '6.2.1'
println "subscript: ['api-prod'] ${viaSubscript['api-prod']}, key is a ${viaSubscript.keySet()[0].getClass().simpleName}"
println "list.contains('api-prod') ${["api-${env}"].contains('api-prod')}"
println "set.contains('api-prod') ${(["api-${env}"] as Set).contains('api-prod')}"
def fixed = [("api-${env}".toString()): '6.2.1']
println "toString() key: ['api-prod'] ${fixed['api-prod']}" from session import jenkins_jar, run, use
# GString keys on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy keys.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy keys.groovy > on-{version}.txt
diff on-{version}.txt on-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain keys.groovy > on-2.4.21.txt
diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
""") Output
$ groovy keys.groovy | tee on-6.0.0.txt
key == 'api-prod' true
key.equals('api-prod') false
hash codes 921144143 and 921144106
map literal: tags['api-prod'] null, key is a GStringImpl
map literal: tags["api-${env}"] null
map literal: tags.get("api-${env}") 6.2.1
put(): ['api-prod'] null
subscript: ['api-prod'] 6.2.1, key is a String
list.contains('api-prod') false
set.contains('api-prod') false
toString() key: ['api-prod'] 6.2.1
# The groovy command is Groovy 5.1.3 from here on.
$ groovy keys.groovy > on-5.1.3.txt
$ diff on-5.1.3.txt on-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy keys.groovy > on-4.0.33.txt
$ diff on-4.0.33.txt on-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain keys.groovy > on-2.4.21.txt
$ diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keys_session.py
The hash codes differ, 921144143 for the GString and 921144106 for the String, and equals is false. So the map
literal and put() kept a GStringImpl key that ['api-prod'] could not find, and the list and the set did not
contain 'api-prod' either. Even tags["api-${env}"], written exactly like the key, returned null: Groovy’s
subscript operator turns a GString into a String, both when it stores a key and when it looks one up. That is why the
map filled with viaSubscript["api-${env}"] = … worked, while Java’s own get(), given the GString, found the
GString key.
Key idea
== compares text, but maps, sets and contains use equals and hashCode, where a GString and a String differ:
turn a GString into a String at the moment it becomes a key.
Mixing the two kinds of key is what goes wrong in practice, and the subscript operator’s conversion hides it from a
quick test that both stores and reads through [].
Building keys and set elements from GStrings
A map literal or put() with "api-${env}" stores a GString, and a later lookup with ['api-prod'] or even with
["api-${env}"] misses it. Call toString() wherever a GString becomes a map key, a set element or a list item
that is searched later, as the fixed map does.
Checkpoint
Check: GStrings as keys in maps, sets and lists
The GDK adds the string methods that Java lacks
The Groovy Development Kit adds methods to Java’s String: operators such as * and -, padding, splitting, indexing
with ranges, and two methods that tidy multi-line text. The first program shows one line of each; the second builds two
multi-line strings with stripIndent() and stripMargin(), and the third shows a trap of stripIndent():
GROOVY (gdk.groovy) · gdk.groovy
// String methods that the Groovy Development Kit adds to Java's String.
def show(String code, Object value) {
println "${code.padRight(30)}${value instanceof CharSequence ? "'$value'" : value}"
}
show "'ab' * 3", 'ab' * 3
show "'build-42-final' - '-final'", 'build-42-final' - '-final'
show "'a-b-c' - '-'", 'a-b-c' - '-'
show "'7'.padLeft(3, '0')", '7'.padLeft(3, '0')
show "'id'.padRight(6, '.')", 'id'.padRight(6, '.')
show "'ok'.center(8, '*')", 'ok'.center(8, '*')
show "'a,,b,'.split(',')", 'a,,b,'.split(',') as List
show "'a,,b,'.split(',', -1)", 'a,,b,'.split(',', -1) as List
show "'a,,b,'.tokenize(',')", 'a,,b,'.tokenize(',')
show "'x=1;y=2'.tokenize(';=')", 'x=1;y=2'.tokenize(';=')
show "'Groovy'[0..2]", 'Groovy'[0..2]
show "'Groovy'[-1]", 'Groovy'[-1]
show "'banana'.count('an')", 'banana'.count('an') GROOVY (multiline.groovy) · multiline.groovy
def report = '''\
Build 42
api: passed
web: failed'''.stripIndent()
println report
def config = '''\
|retries=3
| timeout=30'''.stripMargin()
println config
println "readLines(): ${config.readLines()}" GROOVY (indent.groovy) · indent.groovy
// The closing ''' on a line of its own: Java's stripIndent() and Groovy's stripIndent(true).
def query = '''\
SELECT name
FROM builds
'''
println "stripIndent(): ${query.stripIndent().readLines().collect { "|$it|" }}"
println "stripIndent(true): ${query.stripIndent(true).readLines().collect { "|$it|" }}" from session import jenkins_jar, run, use
# The GDK string methods and multi-line text on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy gdk.groovy | tee gdk-6.0.0.txt
groovy multiline.groovy | tee multiline-6.0.0.txt
groovy indent.groovy | tee indent-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy gdk.groovy > gdk-{version}.txt
groovy multiline.groovy > multiline-{version}.txt
groovy indent.groovy > indent-{version}.txt
diff gdk-{version}.txt gdk-6.0.0.txt && diff multiline-{version}.txt multiline-6.0.0.txt && diff indent-{version}.txt indent-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain gdk.groovy > gdk-2.4.21.txt
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain multiline.groovy > multiline-2.4.21.txt
diff gdk-2.4.21.txt gdk-6.0.0.txt && diff multiline-2.4.21.txt multiline-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain indent.groovy 2>&1 | grep -m2 -o -e '^stripIndent().*' -e 'No signature of method: [^ ]* is applicable for argument types: ([^)]*)'
""") Output
$ groovy gdk.groovy | tee gdk-6.0.0.txt
'ab' * 3 'ababab'
'build-42-final' - '-final' 'build-42'
'a-b-c' - '-' 'ab-c'
'7'.padLeft(3, '0') '007'
'id'.padRight(6, '.') 'id....'
'ok'.center(8, '*') '***ok***'
'a,,b,'.split(',') [a, , b]
'a,,b,'.split(',', -1) [a, , b, ]
'a,,b,'.tokenize(',') [a, b]
'x=1;y=2'.tokenize(';=') [x, 1, y, 2]
'Groovy'[0..2] 'Gro'
'Groovy'[-1] 'y'
'banana'.count('an') 2
$ groovy multiline.groovy | tee multiline-6.0.0.txt
Build 42
api: passed
web: failed
retries=3
timeout=30
readLines(): [retries=3, timeout=30]
$ groovy indent.groovy | tee indent-6.0.0.txt
stripIndent(): [| SELECT name|, | FROM builds|]
stripIndent(true): [|SELECT name|, | FROM builds|]
# The groovy command is Groovy 5.1.3 from here on.
$ groovy gdk.groovy > gdk-5.1.3.txt
$ groovy multiline.groovy > multiline-5.1.3.txt
$ groovy indent.groovy > indent-5.1.3.txt
$ diff gdk-5.1.3.txt gdk-6.0.0.txt && diff multiline-5.1.3.txt multiline-6.0.0.txt && diff indent-5.1.3.txt indent-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy gdk.groovy > gdk-4.0.33.txt
$ groovy multiline.groovy > multiline-4.0.33.txt
$ groovy indent.groovy > indent-4.0.33.txt
$ diff gdk-4.0.33.txt gdk-6.0.0.txt && diff multiline-4.0.33.txt multiline-6.0.0.txt && diff indent-4.0.33.txt indent-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain gdk.groovy > gdk-2.4.21.txt
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain multiline.groovy > multiline-2.4.21.txt
$ diff gdk-2.4.21.txt gdk-6.0.0.txt && diff multiline-2.4.21.txt multiline-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
Groovy 2.4.21 printed the same lines
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain indent.groovy 2>&1 | grep -m2 -o -e '^stripIndent().*' -e 'No signature of method: [^ ]* is applicable for argument types: ([^)]*)'
stripIndent(): [| SELECT name|, | FROM builds|]
No signature of method: java.lang.String.stripIndent() is applicable for argument types: (java.lang.Boolean)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gdk_session.py
'a-b-c' - '-' printed 'ab-c': the GDK documents minus as removing the first occurrence only. split(',') and
tokenize(',') differ in a way that matters for comma-separated data: split kept the empty field between the two
commas and dropped the empty one at the end, split(',', -1) kept both, and tokenize dropped every empty field;
tokenize(';=') split at either character. In the multi-line text, stripIndent() removed the four spaces that every
line shared and kept the extra two, and stripMargin() removed everything up to each line’s |. The backslash after
the opening ''' keeps the text from starting with a newline.
The trap comes from Java. Since Java 15, String has a stripIndent() of its own, and on a String Groovy calls that
method, as the GDK’s documentation of stripIndent(boolean) says. Java counts the indentation of the last line even
when it is empty, so in indent.groovy, whose closing ''' starts a line of its own, stripIndent() removed nothing,
on all four runtimes. stripIndent(true) applies Groovy’s rule, which skips blank lines, and removed the four spaces; it
exists from Groovy 3, and Jenkins’ 2.4.21 answered No signature of method. Close the quotes at the end of the last
line, as multiline.groovy does, and both rules agree.
Lakh and crore grouping
Java’s NumberFormat for the locale en-IN printed ₹12,345,678.50 on Temurin 25. Java’s DecimalFormat uses one
group size, the last one in its pattern, so it cannot write lakh and crore grouping. The GDK methods can: the program
below splits the whole part into pairs with reverse(), collate(2) and join(), and printed ₹1,23,45,678.50.
import java.math.RoundingMode
import java.text.NumberFormat
def amount = 12345678.5
def india = Locale.forLanguageTag('en-IN')
println "NumberFormat for en-IN: ${NumberFormat.getCurrencyInstance(india).format(amount)}"
String groupForIndia(BigDecimal value) {
def (whole, fraction) = value.setScale(2, RoundingMode.HALF_UP).toPlainString().tokenize('.')
if (whole.size() <= 3) return "$whole.$fraction"
def pairs = whole[0..-4].reverse().toList().collate(2)*.join()*.reverse().reverse()
"${pairs.join(',')},${whole[-3..-1]}.$fraction"
}
println "grouped by hand: ₹${groupForIndia(amount)}"
println "grouped by hand: ₹${groupForIndia(999.99)}"
println "grouped by hand: ₹${groupForIndia(100000)}" from session import jenkins_jar, run, use
# Digit grouping on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy india.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy india.groovy > on-{version}.txt
diff on-{version}.txt on-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain india.groovy > on-2.4.21.txt
diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
""") Output
$ groovy india.groovy | tee on-6.0.0.txt NumberFormat for en-IN: ₹12,345,678.50 grouped by hand: ₹1,23,45,678.50 grouped by hand: ₹999.99 grouped by hand: ₹1,00,000.00 # The groovy command is Groovy 5.1.3 from here on. $ groovy india.groovy > on-5.1.3.txt $ diff on-5.1.3.txt on-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines' Groovy 5.1.3 printed the same lines # The groovy command is Groovy 4.0.33 from here on. $ groovy india.groovy > on-4.0.33.txt $ diff on-4.0.33.txt on-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines' Groovy 4.0.33 printed the same lines # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain india.groovy > on-2.4.21.txt $ diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines' Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 india_session.py
In a Jenkinsfile, Groovy fills in placeholders before the shell starts
The Jenkins documentation says that Pipeline “uses rules identical to Groovy for string interpolation”. A
double-quoted argument of an sh step is therefore a GString, and Groovy puts every value into the text before the
shell runs. For a build parameter, that can turn data into a command; for credentials, the documentation is blunt:
“Groovy string interpolation should never be used with credentials.” Step through what reaches the shell:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A parameter with a semicolon. The parameter's value is hello; echo SECOND COMMAND RAN. In a Jenkinsfile it would come from the build's parameters, which the person who starts the build types in.
- Double quotes. For "echo ${statement}" Groovy puts the value into the text before anything runs. The shell receives echo hello; echo SECOND COMMAND RAN, a string with two commands in it.
- Two commands ran. The semicolon ends the first command, so the shell ran both and printed hello and then SECOND COMMAND RAN. Text that was data became a command.
- Single quotes. For 'echo "$STATEMENT"' Groovy builds no GString, so the text reaches the shell unchanged, and the value travels separately, in the environment variable STATEMENT.
- One command, the value as text. The shell expands $STATEMENT inside double quotes as one word, so echo printed the whole value, semicolon included, and ran nothing else.
The program reproduces both cases on your own computer, without Jenkins: it builds two commands from a parameter that
contains a semicolon and runs each with sh -c, with the value also in the environment variable STATEMENT:
// What the shell receives when a parameter goes into a command, two ways.
def statement = 'hello; echo SECOND COMMAND RAN'
def interpolated = "echo ${statement}"
def literal = 'echo "$STATEMENT"'
[interpolated, literal].each { command ->
def builder = new ProcessBuilder('sh', '-c', command).redirectErrorStream(true)
builder.environment().put('STATEMENT', statement)
def printed = builder.start().text.readLines()
println "command text: $command"
println "shell printed: ${printed.join(' | ')}"
} from session import jenkins_jar, run, use
# A parameter in a shell command on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output.
run("""
groovy shell.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy shell.groovy > on-{version}.txt
diff on-{version}.txt on-6.0.0.txt && echo 'Groovy {version} printed the same lines'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain shell.groovy > on-2.4.21.txt
diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines'
""") Output
$ groovy shell.groovy | tee on-6.0.0.txt command text: echo hello; echo SECOND COMMAND RAN shell printed: hello | SECOND COMMAND RAN command text: echo "$STATEMENT" shell printed: hello; echo SECOND COMMAND RAN # The groovy command is Groovy 5.1.3 from here on. $ groovy shell.groovy > on-5.1.3.txt $ diff on-5.1.3.txt on-6.0.0.txt && echo 'Groovy 5.1.3 printed the same lines' Groovy 5.1.3 printed the same lines # The groovy command is Groovy 4.0.33 from here on. $ groovy shell.groovy > on-4.0.33.txt $ diff on-4.0.33.txt on-6.0.0.txt && echo 'Groovy 4.0.33 printed the same lines' Groovy 4.0.33 printed the same lines # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain shell.groovy > on-2.4.21.txt $ diff on-2.4.21.txt on-6.0.0.txt && echo 'Groovy 2.4.21 printed the same lines' Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shell_session.py
With double quotes the shell received two commands and ran both, so SECOND COMMAND RAN appeared. With single quotes
Groovy built no GString, the command text arrived unchanged, and the shell read the value from its environment and
printed it as text. The Jenkins documentation gives the same fix for a Jenkinsfile: send sh a single-quoted command
and let the shell read the parameter or the credential from the environment that Jenkins has filled in.
Spot it when you read a Jenkinsfile
Look for sh "…${…}…" whose placeholder holds a build parameter, an environment value or a credential. Rewrite it
as sh '…"$NAME"…', with the value passed in the environment, and the shell will treat it as data.
Checkpoint
Check: The GDK's string methods · Interpolation in Jenkins sh steps
Practise: strings and GStrings
Work through the string questions below. They begin with whichever idea your answers show to be weakest, and each reason quotes a session of this page:
Practice that adapts to you
Practise this lesson
16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between 'Hi ${name}' and "Hi ${name}" in Groovy?
The single-quoted literal is a plain java.lang.String and keeps the characters ${name} as they are; single-quoted and
triple-single-quoted strings never interpolate. The double-quoted literal is a GString: Groovy evaluates name when the
string is built and puts its value into the text, so it prints Hi Asha when name is 'Asha'. A double-quoted
string without any placeholder is an ordinary String. Because a GString is a different class, it is equal to a String
with the same text under Groovy’s == but not under equals, and it has a different hash code, so it should be turned
into a String with toString() before it is used as a map key. In a Jenkinsfile, single quotes also keep parameters and
secrets out of the command text of an sh step.
Two longer interview questions of this module start from this lesson:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A deployment script builds a map of image tags with keys written "${service}-${env}". Printing the map shows api-prod, but tags['api-prod'] returns null. Explain the cause, give two fixes, and say how a test would have caught it.
-
Mid to senior
Reading a colleague's Jenkinsfile, you see sh "deploy --token ${env.DEPLOY_TOKEN} --target ${params.TARGET}". What two problems does it have, how do you rewrite it, and how would you show that the rewrite fixes them?
Key takeaways
- Single-quoted strings are plain Strings; double-quoted, triple-double-quoted, slashy and dollar-slashy strings fill in
${}and become GStrings, and slashy strings keep backslashes for paths and patterns. - A placeholder is evaluated once, when the GString is built, and keeps the object it got;
${-> expr}is evaluated every time the text is used. - A GString equals a String under Groovy’s
==, but not underequals, and its hash code differs, so maps, sets andcontainsmiss it; convert keys withtoString(). - The GDK adds
*,-,padLeft,center,tokenize, ranges as indexes,stripIndent()andstripMargin();tokenizedrops empty fields wheresplitkeeps the inner ones, and on Java 15 and later a String’sstripIndent()is Java’s own, sostripIndent(true)asks for Groovy’s rule. - In a Jenkinsfile a double-quoted
shargument is filled in by Groovy before the shell runs, so parameters and secrets belong in single-quoted commands that read them from the environment.
Terms in this lesson
- GString
- groovy.lang.GString, the class of a double-quoted string that holds a placeholder such as ${name}. It keeps its text and its values apart and joins them when it is used as text, so it equals a String under == but not under equals.
- hash code
- The int that hashCode() returns, which hash maps and sets use to file and find a key. Equal objects must have equal hash codes, and a GString's hash code differs from that of a String with the same text.
- interpolation
- Filling a placeholder such as ${name} in a string with the value of the expression inside it. Groovy interpolates double-quoted, triple-double-quoted, slashy and dollar-slashy strings, never single-quoted ones.
- slashy string
- A Groovy string written between slashes, such as /\d+/. Every backslash stays as written and only a slash needs escaping, so it suits regular expressions and Windows paths. It fills in ${} placeholders.
References
- Syntax, strings (Groovy 6.0.0 documentation) (The Apache Software Foundation (Apache Groovy))
- Syntax, string interpolation and closures in GStrings (Groovy 6.0.0 documentation) (The Apache Software Foundation (Apache Groovy))
- Syntax, GString and String hashCodes (Groovy 6.0.0 documentation) (The Apache Software Foundation (Apache Groovy))
- CharSequence methods of the Groovy Development Kit (minus, tokenize, stripIndent) (The Apache Software Foundation (Apache Groovy))
- String methods of the Groovy Development Kit (The Apache Software Foundation (Apache Groovy))
- Using a Jenkinsfile, string interpolation (The Jenkins project (jenkins.io))
- Jenkins 2.580.1 bill of materials (bom/pom.xml) (The Jenkins project)
- java.text.DecimalFormat, grouping (Java SE 25 API) (Oracle)
- java.lang.String, stripIndent (Java SE 25 API) (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress