A robot conducting an orchestra.
Photo attribution here.

Ideally, when we have questions about a piece of code, we'd always have a man or help entry (or some other official, canonical resource) to guide us. Failing that, we could plug our question into Google and get an answer from StackOverflow or (worst-case scenario) someone's blog.

But some questions aren't conducive to that. Some questions are specific to our unique situation, and it's unlikely we'll find a resource already written which addresses our issue.

In these cases, we can unblock ourselves by asking ChatGPT our preliminary question, and using its response to refine, rephrase, or completely change our question into something which will help us figure out our next steps.

Caveat- always double-check ChatGPT's answer

We've all heard of hallucinations, i.e. ChatGPT's habit of returning confident-but-incorrect answers to the questions it receives. And it's frequently possible to make ChatGPT second-guess itself back and forth, simply by repeatedly typing "Are you sure?" in response to what it tells you. If your experience is the same as mine, it will reply to this question with some form of "I'm sorry for the confusion, you're right and I'm wrong." Needless to say, this doesn't inspire confidence in the answers it gives us.

For these reasons, it's important that we always double-check with a secondary, trustworthy source. This is actually a good habit to instill when encountering human-generated answers as well, but it's doubly-important with ChatGPT.

Story Time- A Personal Example

Sometimes, when we have a programming question, we aren't even sure what to Google, or we have a vague idea but don't know how to phrase our question. Nor can we post our question on a site like StackOverflow, since sites like those specifically state that your question must be well-formed and broadly-applicable in order to be kept open.

Some great examples of this are:

  • regular expressions
  • parameter expansion in shell scripting
  • instructions for the sed command

More often than not, a particular expression is tailored to fit its concise use case, and is not particularly portable from one application to another. This makes it unlikely that a Google search for your specific regex will return any relevant results. It also makes it likely that someone on StackOverflow will close your question as being overly specific, and therefore not useful to the community at-large.

I experienced this while reading through (and blogging about) the RBENV codebase, in order to combat my impostor's syndrome. One of RBENV's commands is rbenv versions, which lists all the Ruby versions that RBENV currently manages for you on your machine. Among other things, this command sorts these versions.

But sorting version numbers isn't as straightforward as it might appear. We can see what versions RBENV will let us install by running rbenv install --list-all. A sample of the output at the time of this writing is:

3.2.2
3.3.0-dev
3.3.0-preview1
3.3.0-rc1
jruby-9.4.5.0
truffleruby-23.1.2
truffleruby+graalvm-22.0.0.2
truffleruby+graalvm-23.0.0-preview1

These are already sorted, but it's RBENV's job to perform that sorting. To do this, we need to first sort by major version, then by minor version, then by patch version. Complicating this task is the fact that some Ruby versions begin with a string (like "jruby" or "truffleruby"), and only then followed by their major/minor/patch versions.

Additionally, we need to treat the version numbers as numbers (not as strings), while continuing to sort the non-numeric characters (i.e. "truffleruby", "jruby") as the strings that they are. In other words, we need to sort numerically when appropriate, and lexicographically all other times.

To do this, RBENV's versions command implements the following helper function:

sort_versions() {
  sed 'h; s/[+-]/./g; s/.p\([[:digit:]]\)/.z.\1/; s/$/.z/; G; s/\n/ /' | \
    LC_ALL=C sort -t. -k 1,1 -k 2,2n -k 3,3n -k 4,4n -k 5,5n | awk '{print $2}'
}

If you've never used sed before (as was the case for me when I first read this), you might have no idea about where to start looking for answers to what the above does or means.

As you can imagine, Googling the entire string following sed is unlikely to produce any helpful results. We could post a question on StackOverflow, but as I previously mentioned, there's a good chance that question will be closed, since it's not widely applicable to many users. And even if it's not closed, we may have to wait awhile for an answer, if we get one at all.

Instead, we can ask ChatGPT to help us decipher this cryptic code into its components (of which there are clearly many). Once we get an answer, we'll have a series of discrete moving parts, which will be much easier to Google and/or ask StackOverflow about. Let's do that now.

Experiment- Asking ChatGPT for help

I paste the following question into ChatGPT:

I am reading through the codebase of RBENV, a version manager for Ruby. One of this program's commands is `rbenv versions`, which lists all the Ruby versions that RBENV currently manages for me on my machine. Inside the file for this command, I see the following helper method:

sort_versions() {
  sed 'h; s/[+-]/./g; s/.p\([[:digit:]]\)/.z.\1/; s/$/.z/; G; s/\n/ /' | \
    LC_ALL=C sort -t. -k 1,1 -k 2,2n -k 3,3n -k 4,4n -k 5,5n | awk '{print $2}'
}
I don't understand the string following the `sed` command. Please explain what it does.

In response, ChatGPT gives me the following answer:

Sure, let's break down the sed command:

sort_versions() {
  sed 'h; s/[+-]/./g; s/.p\([[:digit:]]\)/.z.\1/; s/$/.z/; G; s/\n/ /' | \
    LC_ALL=C sort -t. -k 1,1 -k 2,2n -k 3,3n -k 4,4n -k 5,5n | awk '{print $2}'
}

Here's what each part does:

  1. h: This command copies the pattern space (the current line) to the hold space (a temporary buffer).
  2. s/[+-]/./g: This command replaces all occurrences of + or - characters with a .. This is typically done to transform version strings like 2.7.3-p1 into 2.7.3.p1.
  3. s/.p\([[:digit:]]\)/.z.\1/: This command replaces the pattern .p followed by a digit with .z. and the same digit. This typically handles patch versions, converting something like .p123 into .z.123.
  4. s/$/.z/: This command appends .z to the end of each line.
  5. G: This command appends a newline followed by the contents of the hold space (the original line) to the pattern space (the modified line).
  6. s/\n/ /: This command replaces the newline character added by the G command with a space. This effectively joins the original version string with its modified form.

The purpose of this sed command seems to be to normalize version strings, replacing certain characters and adding suffixes to make them easier to sort. This is necessary because versions like 2.7.3-p1 need to be sorted alongside versions like 2.7.3.p1, and this script ensures they are sorted correctly.

With this response, we've taken the first steps toward breaking our intractable problem into multiple problems of a manageable size. Again, this answer may not be accurate- that's one of the drawbacks of relying on ChatGPT. But I'd argue that this still represents forward progress, since we now have things we can effectively Google.

For example, prior to asking this question, I didn't even know how to delimit the multiple commands in this string. I had no way of even knowing whether it was multiple commands, or one large command. When you're a shell noob who doesn't know what he doesn't know, questions which might seem obvious to old shell hands can represent real stumbling blocks.

It wasn't until I saw how ChatGPT itemized the commands that I noticed the semi-colons in the sed string, and put 2 and 2 together.

With this step forward, I now have multiple questions I can Google. For example:

  • What do "pattern space" and "hold space" mean, in the context of sed?
  • I notice that s/.p\([[:digit:]]\)/.z.\1/ in step 2 looks a lot like a regular expression. Is that the case? And are there any differences between regexes in sed and regexes in (for example) Ruby, my default language?
  • Line 3 says that the code s/.p\([[:digit:]]\)/.z.\1/ "...replaces the pattern .p followed by a digit with .z. and the same digit." What is the process by which it captures "the same digit", and re-use it later?

These questions can be further broken down into sub-questions. For example, we can repeat the above process with the regex s/.p\([[:digit:]]\)/.z.\1/, asking ChatGPT to break this down for us:

Can you further break down the following code from line 3:

s/.p\([[:digit:]]\)/.z.\1/

It answers with the following:

Certainly! Let's break down the s/.p\([[:digit:]]\)/.z.\1/ command:

  • s/: This is the substitute command in sed, used for replacing text patterns.
  • .: This matches any character.
  • p: This matches the character 'p' literally.
  • \([[:digit:]]\): This is a capture group (\( and \)), capturing a single digit ([[:digit:]]). The [[:digit:]] is a character class matching any digit (0-9).
  • /: This separates the pattern to match and the replacement text.
  • .z.: This is the replacement text. It's a literal string that replaces the matched pattern. It adds .z. before the captured digit.
  • \1: This refers to the first capture group, which in this case is the single digit matched by [[:digit:]]. It's used in the replacement text to insert the matched digit after .z..

So, in summary, this command looks for any occurrence of .p followed by a single digit in the version string and replaces it with .z. followed by the same digit. For example, 2.7.3-p1 would be transformed into 2.7.3.z1.

From this, I can add the questions "What is a capture group?" and "What are character classes?" to my running list of questions. Once I've Googled those questions to get a second opinion, I can then run simple experiments with each of these commands. For example:

  • Adding data from the pattern space to the hold space (and vice versa), using h and G.
  • Making arbitrary modifications to the pattern space using the s/ instruction.
  • Using character classes and capture groups within my search-and-replace instructions.

In retrospect, we can now see that my original question of "What does this huge sed command do?" is really the sum of quite a few smaller questions. In general, the more we can break up a large question into these smaller questions, the more receptive people will be to help us answer them on Q&A sites, and the easier it will be to Google for answers.

What the code actually does

As it turns out, ChatGPT is not far off. The purpose of this method is indeed to normalize version strings. It takes input such as this...

2.7.5
3.0.0
truffleruby-23.1.2
3.1.0
3.2.2
mruby-3.2.0

...and turns it into this:

2.7.5.z 2.7.5 3.0.0.z 3.0.0 truffleruby.23.1.2.z truffleruby-23.1.2 3.1.0.z 3.1.0 3.2.2.z 3.2.2 mruby.3.2.0.z mruby-3.2.0

This represents a single long string containing each version number, in unsorted order. Each version is prefixed with a modified version of itself which is much easier to sort than the original. From there, this string is piped to the sort command, which takes multiple instances of the -k flag. This sorts our version numbers by this prefix value, resulting in this:

2.7.5.z 2.7.5 3.0.0.z 3.0.0 3.1.0.z 3.1.0 3.2.2.z 3.2.2 mruby.3.2.0.z mruby-3.2.0 truffleruby.23.1.2.z truffleruby-23.1.2

Finally, we pipe this result to the awk command, which prints the 2nd field from each entry (i.e. the version number we care about), without the prefix. This leaves us with the human-readable, sorted version numbers we want:

2.7.5 3.0.0 3.1.0 3.2.2 mruby-3.2.0 truffleruby-23.1.2

A subsequent step (which we won't examine here) iterates over each of these entries, printing them on separate lines.

Note that the first -k flag in our sort command does not have an n at the end, indicating that we treat the first sort field as a string. This allows us to compare a version number like 3.2.2 with a version like mruby-3.2.0, by treating major version numbers as strings.

This will eventually become a problem when we reach Ruby version 10, because a version number like 2.0.0 will come after a number like 10.0.0. But we are a long way from Ruby version 10, so this is a problem for another day.

Alternate use case- confirming your understanding

Even if you think you understand what a line of code does, it can be helpful to plug that code into ChatGPT anyway, and ask it what the code does. Who knows, ChatGPT might return an answer which differs from what you expected. You can then go about Googling who is right, you or the machine. You might discover that your preconception was incorrect, and thus save yourself from going down a wrong path.

Photo Attribution

Title: Robot_conductor_'EveR 6'_03

Description: Press release of robot conductor June 26, 2023 National Theater of Korea, Jongno-gu, Seoul KOCIS(Korean Culture and Informatioon Service) Official Photographer : KIM SUNJOO

Author: Republic of Korea

Source: Flickr

License: CC BY-SA 2.0 DEED Attribution-ShareAlike 2.0 Generic