Perl Weekly Challenge 347‘s tasks are formatting dates and phone numbers, so I’m offering up ELO’s Telephone Line as this week’s musical theme.
Doo-wop, doo-be-doo-doo-wop, doo-wah, doo-languages…
Task 1: Format Date
You are given a date in the form: 10th Nov 2025.
Write a script to format the given date in the form: 2025-11-10 using the set below.
@DAYS = ("1st", "2nd", "3rd", ....., "30th", "31st")
@MONTHS = ("Jan", "Feb", "Mar", ....., "Nov", "Dec")
@YEARS = (1900..2100)
Example 1
Input: $str = "1st Jan 2025"
Output: "2025-01-01"
Example 2
Input: $str = "22nd Feb 2025"
Output: "2025-02-22"
Example 3
Input: $str = "15th Apr 2025"
Output: "2025-04-15"
Example 4
Input: $str = "23rd Oct 2025"
Output: "2025-10-23"
Example 5
Input: $str = "31st Dec 2025"
Output: "2025-12-31"
Approach
Well, how difficult this will be to do depends on how much validation we want to do of the input. Do we want to accept days with the incorrect ordinal (22th instead of 22nd)? Do we want to accept years outside the given year range (2525)?
I’m going to take the approach that I’m going to do some validation on the input first, then format the date, even though that’s going to wind up being most of the solution.
Validating the days could be done by building a list of @DAYS like shown above, or we could use a regular expression to grab the numeric portion and the ordinal, and then test to see if the number is 1, 21, or 31, the ordinal should be “st”, if the number is 2 or 22, the ordinal should be “nd”, if the number is 3 or 23, the ordinal should be “rd”. and otherwise the ordinal should be “th”. Similarly, the years we can just compare 1900 <= year <= 2100. The months, however, make sense to enumerate in their entirety.
Raku
But, because I need to not only check to see if the month provided is in the set of @MONTHS, but then translate that to a number, rather than storing the set as an array, I’m storing it as a hash, which allows me to test for membership AND translate to a numeric value.
One of the things I like about Raku is the ability to do a conditional like 1900 <= $year <= 2100.
my %months = (
Jan => 1, Feb => 2, Mar => 3, Apr => 4, May => 5, Jun => 6,
Jul => 7, Aug => 8, Sep => 9, Oct => 10, Nov => 11, Dec => 12
);
sub formatDate($str) {
my ($day, $month, $year) = $str.split(" ");
my $m = $day ~~ /(\d+)(\D+)/;
return "$day has the incorrect ordinal"
unless
(($m[0] == 1 || $m[0] == 21 || $m[0] == 31) && $m[1] eq "st")
||
(($m[0] == 2 || $m[0] == 22) && $m[1] eq "nd")
||
(($m[0] == 3 || $m[0] == 23) && $m[1] eq "rd")
||
$m[1] eq "th";
$day = $m[0]; # grab just the numeric portion
return "Unknown month '$month'"
unless %months{$month}:exists;
$month = %months{$month}; # convert to numeric
return "Year must be between 1900-2100"
unless 1900 <= $year <= 2100;
my $date;
try {
$date = Date.new($year, $month, $day);
}
return $! if $!;
return qq/"$date"/;
}View the entire Raku script for this task on GitHub.
$ raku/ch-1.raku
Example 1:
Input: $str = "1st Jan 2025"
Output: "2025-01-01"
Example 2:
Input: $str = "22nd Feb 2025"
Output: "2025-02-22"
Example 3:
Input: $str = "15th Apr 2025"
Output: "2025-04-15"
Example 4:
Input: $str = "23rd Oct 2025"
Output: "2025-10-23"
Example 5:
Input: $str = "31st Dec 2025"
Output: "2025-12-31"
Example Year Too Big:
Input: $str = "31st Dec 2525"
Output: Year must be between 1900-2100
Example Year Too Small:
Input: $str = "31st Dec 1825"
Output: Year must be between 1900-2100
Example Bad Ordinal:
Input: $str = "31nd Dec 2025"
Output: 31nd has the incorrect ordinal
Example Bad Month:
Input: $str = "30th Avril 2025"
Output: Unknown month 'Avril'
Example Bad Date:
Input: $str = "31st Feb 2025"
Output: Day out of range. Is: 31, should be in 1..28Perl
There wasn’t much translation necessary going from Raku to Perl. I was able to directly capture the result of the regex match, instead of needing to put it in a match object. and my year conditional needed an and and repeating the variable: 1900 <= $year && $year <= 2100.
I used Time::Local to translate the year, month, day into a epoch timestamp, and then Time::Piece to format that as a date string.
use Time::Local;
use Time::Piece;
my %months = (
Jan => 1, Feb => 2, Mar => 3, Apr => 4, May => 5, Jun => 6,
Jul => 7, Aug => 8, Sep => 9, Oct => 10, Nov => 11, Dec => 12
);
sub formatDate($str) {
my ($day, $month, $year) = split / /, $str;
my ($dnum, $dord) = $day =~ /(\d+)(\D+)/;
return "$day has the incorrect ordinal"
unless
(($dnum == 1 || $dnum == 21 || $dnum == 31) && $dord eq "st")
||
(($dnum == 2 || $dnum == 22) && $dord eq "nd")
||
(($dnum == 3 || $dnum == 23) && $dord eq "rd")
||
$dord eq "th";
$day = $dnum; # grab just the numeric portion
return "Unknown month '$month'"
unless exists $months{$month};
$month = $months{$month}; # convert to numeric
return "Year must be between 1900-2100"
unless 1900 <= $year && $year <= 2100;
my $date;
eval {
my $epoch = timelocal(0, 0, 0, $day, $month-1, $year);
$date = Time::Piece->new($epoch);
};
if (my $err = $@) { # get rid of line info in the error
$err =~ s{at .+ line \d+.\n}{};
return $err;
}
return qq/"@{[ $date->date ]}"/;
}View the entire Perl script for this task on GitHub.
Python
Python has a match object like Raku, and I was able to catch the exception from a bad date without much fuss.
from datetime import date
import re
months = {
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
}
def format_date(dstr):
day, month, year = dstr.split()
m = re.match(r'(\d+)(\D+)', day)
dnum = int(m.group(1)) # make sure it's an int
dord = m.group(2)
if not (
((dnum == 1 or dnum == 21 or dnum == 31) and dord == "st")
or
((dnum == 2 or dnum == 22) and dord == "nd")
or
((dnum == 3 or dnum == 23) and dord == "rd")
or
dord == "th"
):
return f"{day} has the incorrect ordinal"
day = dnum # grab just the numeric portion
if not month in months:
return f"Unknown month '{month}'"
month = months[month] # convert to numeric
year = int(year) # make sure it's an int
if not (1900 <= year <= 2100):
return "Year must be between 1900-2100"
try:
dateobj = date(year, month, day)
except ValueError as err:
return err
return f'"#{dateobj.strftime("%Y-%m-%d")}"'View the entire Python script for this task on GitHub.
Elixir
For Elixir, I offloaded most of the validation to guards on the format_date/4 function, but I couldn’t think of a way to do the multipart if validation for the ordinal via a guard. Fortunately, I was able to make it a condition to return from format_date/1 with an error. I also like that Date.new/4 doesn’t throw an exception by default. If you want an exception, you use Date.new!/4.
def format_date(_, _, _, year) when year < 1900,
do: "Year must be between 1900-2100"
def format_date(_, _, _, year) when year > 2100,
do: "Year must be between 1900-2100"
def format_date(_, month, mnum, _) when is_nil(mnum),
do: "Unknown month '#{month}'"
def format_date(dnum, _, mnum, year) do
with {:ok, date} <- Date.new(year, mnum, dnum) do
"\"#{date |> Date.to_iso8601}\""
else
{:error, :invalid_date} -> "Invalid date"
end
end
def format_date(str) do
[day, month, year] = String.split(str)
m = Regex.named_captures(~r'(?<dnum>\d+)(?<dord>\D+)', day)
dnum = m["dnum"] |> String.to_integer # make sure it's an int
if not (
((dnum == 1 or dnum == 21 or dnum == 31) and
m["dord"] == "st")
or
((dnum == 2 or dnum == 22) and m["dord"] == "nd")
or
((dnum == 3 or dnum == 23) and m["dord"] == "rd")
or
m["dord"] == "th"
) do
"#{day} has the incorrect ordinal"
else
year = year |> String.to_integer # make sure it's an int
mnum = Map.get(%{
"Jan" => 1, "Feb" => 2, "Mar" => 3, "Apr" => 4,
"May" => 5, "Jun" => 6, "Jul" => 7, "Aug" => 8,
"Sep" => 9, "Oct" => 10, "Nov" => 11, "Dec" => 12
}, month)
format_date(dnum, month, mnum, year)
end
endView the entire Elixir script for this task on GitHub.
Task 2: Format Phone Number
You are given a phone number as a string containing digits, space and dash only.
Write a script to format the given phone number using the below rules:
1. Removing all spaces and dashes
2. Grouping digits into blocks of length 3 from left to right
3. Handling the final digits (4 or fewer) specially:
- 2 digits: one block of length 2
- 3 digits: one block of length 3
- 4 digits: two blocks of length 2
4. Joining all blocks with dashes
Example 1
Input: $phone = "1-23-45-6"
Output: "123-456"
Example 2
Input: $phone = "1234"
Output: "12-34"
Example 3
Input: $phone = "12 345-6789"
Output: "123-456-789"
Example 4
Input: $phone = "123 4567"
Output: "123-45-67"
Example 5
Input: $phone = "123 456-78"
Output: "123-456-78"
Approach
Really, this is the simpler of the two. The rules lay it all out: we strip all non-digits from the string, then we pull off three characters from the front of the string until the length of the string is 4 or less characters, then we group the remaining characters as directed.
Elixir
Because I had an inspiration about how to do this recursively with multiple dispatch, I decided to do the Elixir solution first. One thing I knew was that I wanted to immediately break the string into a list of characters, because while you can’t test the length of a string in a function’s guard clause, you can test the length of a list. However, I wasn’t sure of how to pull elements off the left-hand side of the list. List.first/2 would give me the first element, but it wouldn’t return the list with the first element removed. After a bit of searching, I found the functions I wanted… in the Kernel module!
Kernel.hd/1returns the first element, or “head”, of a listKernel.tl/1returns all but the first element of a list, or the “tail” of the list
Plus, because they’re Kernel modules, I wouldn’t need to qualify them with Kernel., I could just use hd() and tl().
I think this shows off the power of multiple dispatch pretty well: the format_phone/1 function (lines 21-26) accepts the string, strips out all non-numeric digit characters, splits the string into a list of those characters, and then passes the list to format_phone/2 with an empty formatted string.
We then have three multiple dispatch definitions of format_phone/2: one on lines 14-19 that handles all of the rule 2 processing (pulling the first three characters off the list, and then appending them to the formatted string along with a trailing dash, and then recursively calling format_phone/2), one on lines 8-12 that handles the list when it’s down to 4 elements (pulling the first two characters off the list, appending them to the formatted string, adding a dash to the formatted string, then joining and appending the remaining two characters to the formatted string and returning it), and one on lines 4-7 that handles the list when it’s less than 4 elements long (joining the remaining characters and appending them to the formatted string, which already has a trailing dash, and then returning that result).
def format_phone(list, formatted) when length(list) < 4 do
formatted <> Enum.join(list)
end
def format_phone(list, formatted) when length(list) == 4 do
{a, list} = {hd(list), tl(list)}
{b, list} = {hd(list), tl(list)}
formatted <> "#{a}#{b}-" <> Enum.join(list)
end
def format_phone(list, formatted) when length(list) > 4 do
{a, list} = {hd(list), tl(list)}
{b, list} = {hd(list), tl(list)}
{c, list} = {hd(list), tl(list)}
format_phone(list, formatted <> "#{a}#{b}#{c}-")
end
def format_phone(phone) do
phone
|> String.replace(~r/\D/, "")
|> String.graphemes
|> format_phone("")
endView the entire Elixir script for this task on GitHub.
$ elixir/ch-2.exs
Example 1:
Input: $phone = "1-23-45-6"
Output: "123-456"
Example 2:
Input: $phone = "1234"
Output: "12-34"
Example 3:
Input: $phone = "12 345-6789"
Output: "123-456-789"
Example 4:
Input: $phone = "123 4567"
Output: "123-45-67"
Example 5:
Input: $phone = "123 456-78"
Output: "123-456-78"Raku
And, as always happens when I do the Elixir solution first, it informs how I do the Raku version. Because Raku allows us to put where clauses testing string length into our function signatures, I didn’t need to pass the string around as a list of characters. And the Str class’ .substr routine let me pull the characters I wanted out of the string easily.
multi formatPhone($phone where $phone.chars < 4, $formatted) {
$formatted ~ $phone;
}
multi formatPhone($phone where $phone.chars == 4, $formatted) {
$formatted ~ $phone.substr(0..1) ~ "-" ~ $phone.substr(2..3);
}
multi formatPhone($phone, $formatted) {
formatPhone(
$phone.substr(3),
$formatted ~ $phone.substr(0..2) ~ "-"
);
}
multi formatPhone(Str $phone is copy) {
$phone ~~ s:global/\D+//;
formatPhone($phone, "");
}View the entire Raku script for this task on GitHub.
Perl
Even though Perl doesn’t have multiple dispatch, I didn’t miss it that much. I just gave the recursive function a different name, and put all the clauses in an if-elsif-else block within that function.
sub formatPhone2($phone, $formatted) {
if (length($phone) < 4) {
$formatted . $phone;
}
elsif (length($phone) == 4) {
$formatted . substr($phone, 0,2) . "-" . substr($phone, 2,2);
}
else {
formatPhone2(
substr($phone, 3),
$formatted . substr($phone, 0, 3) . "-"
);
}
}
sub formatPhone($phone) {
$phone =~ s/\D+//g;
formatPhone2($phone, "");
}View the entire Perl script for this task on GitHub.
Python
The big thing I need to remember in Python after writing Elixir, Raku, and Perl solutions first is that I have to explicitly return values from functions.
import re
def format_phone2(phone, formatted):
if len(phone) < 4:
return formatted + phone
elif len(phone) == 4:
return formatted + phone[0:2] + "-" + phone[2:4]
else:
return format_phone2(
phone[3:],
formatted + phone[0:3] + "-"
)
def format_phone(phone):
phone = re.sub(r'\D', '', phone)
return format_phone2(phone, "")View the entire Python script for this task on GitHub.
Here’s all my solutions in GItHub: https://github.com/packy/perlweeklychallenge-club/tree/master/challenge-347/packy-anderson