Convert Perl 5 scripts to Go, and learn Go from your own code while you do it.
perl2golang reads a Perl script and writes a Go project. It produces two
copies of the same program: a plain one that reads like Go somebody wrote by
hand, and an annotated one, buried in comments explaining what each Go construct
is, why it is written that way, and which line of your Perl it came from.
Alongside them it writes a set of documents: a walkthrough tying regions of your
script to regions of the output, a lesson for every Go concept your code
actually touched, and an honest account of anything it could not translate.
The conversion is not meant to be a 100% complete, as there are many concepts and behaviors that don't translate very well and would require human judgement to translate into applicable Go variants. The point is to produce something that is usable while also educating the user about Golang to facilitate their learning of the language.
Everything runs on your machine and nothing you convert goes anywhere. There is no external service, no account, no API key, no telemetry, and no network connection of any kind; your Perl is read, never executed, and never sent anywhere.
- Install
- Platforms
- Use
- What the output looks like
- The interactive session
- What you get
- Honesty about what it could not do
- What the numbers mean
- Known limitations
- Out of scope
- Working on perl2golang
- License
Download the archive for your system from the releases page, unpack it, and put
the perl2golang binary on your PATH. It is one static binary with nothing
beside it: no Go toolchain, no Perl, no libraries to install first. Each
release also carries a SHA256SUMS file to check the download against.
To build it yourself instead, with Go 1.24 or newer:
make build # builds ./bin/perl2golang
make install # installs perl2golang into ~/.local/bin (set BINDIR for elsewhere)
make help lists every target. Either way, building the Go the tool writes
needs a toolchain of 1.23 or newer.
Binaries are published for Linux, macOS and Windows, on 64-bit Intel and on ARM. Every one of them is built from the same source in the same release. The project is developed and tested on Linux, which is where the test suite and the corpus run, so that is the platform the tool is exercised hardest on.
Two things do not work the same everywhere:
- On Windows the session reads whole lines instead, because it cannot put the terminal into raw mode. See "Known limitations".
- A converted program that runs a command written as one string hands it to
sh, which is what the original did. On Windows that needs a POSIX shell to be present.
Convert a file. The output goes to <name>-go/ unless you say otherwise:
perl2golang convert report.pl
perl2golang report.pl -o /tmp/report-go
Convert a snippet and print the result instead of writing files:
perl2golang -e 'my %count; $count{$_}++ for @ARGV; print "$_ => $count{$_}\n" for sort keys %count'
Read Perl from standard input:
cat report.pl | perl2golang -
Look a concept up directly, without converting anything:
perl2golang explain slice-aliasing-and-copy
perl2golang explain --list
The lessons cover the language itself (types and zero values, nil, slices,
maps, errors, interfaces, pointers, goroutines), the parts of the standard
library a script actually lands on (fmt, strings, strconv, sort,
bufio, regexp, os/exec, encoding/json, time, path/filepath,
flag), and the tooling around them (go test and the table-driven habit,
benchmarks, coverage, the race detector, go vet). Every Go sample in them is
compiled and run by this repository's test suite, and the output each lesson
shows is the output its code actually produces.
Three short scripts and the Go this tool writes for them. Each Go program below is the tool's real output, pasted unedited, and each pair was run side by side and printed identical bytes.
A word count, the classic hash-and-sort shape:
my %count;
while (my $line = <STDIN>) {
chomp $line;
$count{lc $_}++ for split /\W+/, $line;
}
delete $count{''};
for my $word (sort { $count{$b} <=> $count{$a} || $a cmp $b } keys %count) {
printf "%5d %s\n", $count{$word}, $word;
}package main
import (
"bufio"
"cmp"
"fmt"
"maps"
"os"
"regexp"
"slices"
"strings"
)
// pattern2 matches the pattern \W+.
var pattern2 = regexp.MustCompile("\\W+")
// main is the program's entry point.
func main() {
count := map[string]int{}
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
for _, item := range splitPattern(pattern2, line, 0) {
count[strings.ToLower(item)]++
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(255)
}
delete(count, "")
sorted := slices.Clone(slices.Collect(maps.Keys(count)))
slices.SortStableFunc(sorted, func(a string, b string) int {
return cmp.Or(cmp.Compare(count[b], count[a]), strings.Compare(a, b))
})
for _, word := range sorted {
fmt.Printf("%5d %s\n", count[word], word)
}
}splitPattern is one of the small helpers the tool writes into helpers.go
when the program needs it, because Perl's split drops trailing empty fields
and Go's regexp.Split does not.
A log scan: arguments, open or die with the right exit status, a capture:
my $file = shift @ARGV or die "usage: $0 LOGFILE\n";
open my $fh, '<', $file or die "cannot open $file: $!\n";
my %seen;
while (<$fh>) {
next unless /ERROR\s+\[(\w+)\]/;
$seen{$1}++;
}
close $fh;
print "$_: $seen{$_}\n" for sort keys %seen;package main
import (
"bufio"
"fmt"
"maps"
"os"
"regexp"
"slices"
)
// errorValPattern2 matches the pattern ERROR\s+\[(\w+)\].
var errorValPattern2 = regexp.MustCompile("ERROR\\s+\\[(\\w+)\\]")
// args holds the command line arguments, without the program name.
var args = os.Args[1:]
// main is the program's entry point.
func main() {
var first string
if len(args) > 0 {
first, args = args[0], args[1:]
}
file := first
if !truthy(file) {
fmt.Fprint(os.Stderr, "usage: "+os.Args[0]+" LOGFILE\n")
os.Exit(255)
}
fh, err := os.Open(file)
if err != nil {
fmt.Fprint(os.Stderr, "cannot open "+file+": "+errnoText(err)+"\n")
os.Exit(255)
}
seen := map[string]int{}
scanner := bufio.NewScanner(fh)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text() + "\n"
_ = line
m := errorValPattern2.FindStringSubmatch(line)
if m == nil {
continue
}
seen[m[1]]++
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(255)
}
fh.Close()
for _, item := range slices.Sorted(maps.Keys(seen)) {
fmt.Printf("%s: %d\n", item, seen[item])
}
}A bless-based class, which becomes a struct with methods:
package Tally;
sub new {
my ($class, %args) = @_;
my $self = { name => $args{name}, total => 0 };
return bless $self, $class;
}
sub add {
my ($self, $amount) = @_;
$self->{total} += $amount;
return $self;
}
sub report {
my $self = shift;
return sprintf "%s: %d", $self->{name}, $self->{total};
}
package main;
my $t = Tally->new(name => 'widgets');
$t->add($_) for 1 .. 4;
print $t->report, "\n";package main
import "fmt"
// Tally is one Tally and everything it knows about itself.
type Tally struct {
Name string
Total int
}
// NewTally builds a Tally.
func NewTally(name string) *Tally {
self := &Tally{Name: name, Total: 0}
return self
}
func (t *Tally) Add(amount int) *Tally {
t.Total += amount
return t
}
func (t *Tally) Report() string {
return fmt.Sprintf("%s: %d", t.Name, t.Total)
}
// main is the program's entry point.
func main() {
t := NewTally("widgets")
for i := 1; i <= 4; i++ {
t.Add(i)
}
fmt.Print(t.Report(), "\n")
}The output is not always this clean. A script leaning on nested references,
context tricks or code generation comes out with any-typed values, helper
calls and TODO markers, and the report says so entry by entry. These three
are honest examples of the ordinary case, not the worst one.
perl2golang repl gives you a prompt. Type Perl, see the Go it becomes, and see
the Go concepts behind it. It is the fastest way to answer "what does this look
like in Go", and the answer is the same Go a file conversion would produce.
$ perl2golang repl
perl2golang 1.0.0 type Perl, see the Go. :help for commands, :quit to leave.
perl> my @nums = (3, 1, 4, 1, 5);
nums := []int{3, 1, 4, 1, 5}
concepts: var-vs-short-declaration, slices-not-arrays (:explain to expand)
perl> my %seen;
seen := map[string]any{}
concepts: nil-slices-vs-nil-maps (:explain to expand)
perl> $seen{$_}++ for @nums;
seen := map[string]int{}
for _, item := range nums {
seen[strconv.Itoa(item)]++
}
(that replaced 1 line shown earlier; :go full shows the session as it stands)
concepts: explicit-conversions-no-coercion, range-is-not-foreach (:explain to expand)
perl> sub trim {
...> my $s = shift;
...> $s =~ s/^\s+|\s+$//g;
(sub body opened at line 1; a blank line twice discards it)
...> return $s;
...> }
// pattern2 matches the pattern ^\s+|\s+$.
var pattern2 = regexp.MustCompile("^\\s+|\\s+$")
func trim(s any) any {
s = pattern2.ReplaceAllString(toText(s), "")
return s
}
(support code added: toText; :go full includes it)
concepts: replace-and-expansion, submatch-and-named-groups (+2 more; :explain to expand, :diag for the full note)
Three things in that transcript are the point of the feature:
- The session holds a program, not a list of snippets.
my %seenbecame amap[string]anyand then amap[string]intonce the next line showed what goes in it. When re-inference changes something already printed, the session says so instead of quietly contradicting itself. - A snippet that spans lines needs no continuation marker. The prompt keeps
reading until the snippet parses, so
sub trim {simply carries on. Two blank lines in a row throw away whatever is half-typed. - The concepts line names what the snippet touched, once per session.
:explainexpands the last snippet's concepts, and:explain <id>prints the whole lesson, the same lesson a conversion writes intodocs/concepts/.
Meta commands, all listed by :help:
:go [full] |
reprint the last Go, or the whole session ready to paste |
:explain [WHAT] |
expand a concept, a P2G code, or the last snippet |
:concepts |
every concept this session touched, with its title |
:why |
the converter's reasoning for the last snippet |
:diag |
the last snippet's diagnostics in full, with source and carets |
:vars |
the variables in scope and the Go type inferred for each |
:perl |
the Perl the session holds so far |
:mode clean|annotated |
plain Go, or Go with the reasoning in comments |
:notes on|off |
show or hide the concept line |
:save FILE / :load FILE |
write the session out, or type a file in |
:reset / :clear |
forget the session, or clear the screen |
:quit |
leave; :q and Ctrl-D do the same |
Nothing ends the session except :quit, Ctrl-D and a signal. Perl that does
not parse is reported with its position and leaves the session program
untouched; a construct with no Go equivalent shows the refusal and what to
write instead.
At a terminal you get line editing and history: arrow keys, Ctrl-A/Ctrl-E,
word movement with Alt-B/Alt-F, Ctrl-K/Ctrl-U/Ctrl-W, history with the
up and down arrows and Ctrl-R to search it, and Ctrl-C to throw away the
snippet you are typing without leaving. History is kept in
$XDG_STATE_HOME/perl2golang/history; --no-history turns that off.
A session also works from a pipe, prompts and all, which makes a transcript something you can save, read, diff and replay:
perl2golang repl < session.pl > transcript.txt
Converting report.pl produces report-go/:
report-go/
go.mod
main.go the program, as ordinary Go
helpers.go small support functions, only the ones used
annotated/
main.go the same program, explained line by line
README.md how to build and run both of them
docs/
start-here.md what was produced and what to read first
walkthrough.md your Perl beside the Go it became, region by region
conversion-report.md what converted, what was approximated, what was not
not-translated.md every gap, with what to do about it by hand
exercises.md checkable tasks against your own generated code
go-for-perl-developers.md the general orientation
concepts/ one lesson per Go concept your code touched
Both programs compile and behave the same way. The annotations are comments and nothing else, which is a test in this repo rather than a promise.
Run either one:
cd report-go
go run . # the plain program
go run ./annotated # the annotated one
The generated project has no dependencies. go.mod names the module and the
language version and nothing else.
Every conversion produces a report. Anything approximated or refused appears
three ways: a TODO in the generated code, an entry in the report with a
stable diagnostic code, and a line in the terminal summary.
perl2golang explain P2G4004 prints the full entry for any code.
A refusal names the construct, says why Go cannot express it the same way, and tells you what to write instead. That is the intended output for the parts of Perl that have no Go counterpart, not a failure to be worked around.
A refusal never stops the program. Where a construct could not be converted the
code calls notImplemented, which hands back the zero value of the type that
position wanted and carries on, so a script with five refusals in it still
builds, still runs, and still does the parts that converted. The first time the
program reaches one it prints a line beginning TODO on standard error and
keeps going, and standard output is left alone so the two programs can still be
compared. Search the generated code for notImplemented to find every gap, or
for a diagnostic code to find one. Nothing the stand-in returns is an answer:
treat anything downstream of a gap the program actually reached as unproven
until you have written the real code in its place.
Pass --strict to make any refusal a nonzero exit status, which is what you
want in a script.
This repository carries a corpus of 271 Perl programs whose real output has
been recorded by running them under perl. make score converts every one of
them, builds the Go that comes out, runs it on the same input, and compares.
The table below is that run at this version, and it is the whole measurement:
tier entries translated typed emitted compiled equivalent honest
tier1 48 47/48 (98%) 38/48 (79%) 48/48 (100%) 48/48 (100%) 45/48 (94%) -
tier2 118 109/118 (92%) 82/118 (69%) 118/118 (100%) 118/118 (100%) 88/118 (75%) -
tier3 37 28/37 (76%) 14/37 (38%) 37/37 (100%) 37/37 (100%) 24/37 (65%) -
tier4 43 27/43 (63%) 30/43 (70%) 43/43 (100%) 42/43 (98%) - 41/43 (95%)
domain 25 25/25 (100%) 15/25 (60%) 25/25 (100%) 24/25 (96%) 23/25 (92%) -
TOTAL 271 236/271 (87%) 179/271 (66%) 271/271 (100%) 269/271 (99%) 180/228 (79%) 41/43 (95%)
The rows are difficulty bands. Tiers 1 and 2 are ordinary Perl, tier 3 is
object systems, parsers and process control, domain/ is realistic sysadmin
and data-wrangling scripts, and tier 4 is Perl this tool is not expected to
convert at all, kept to check that it says so truthfully.
Each column counts whole programs, and each one means something narrower than its name suggests.
translated, 87%. Every construct in the program produced either Go code or
a written note saying why it did not. A single refusal anywhere fails the whole
file, which is why this number sits below compiled. It proves that nothing
went missing unannounced. It proves nothing about whether the Go is right.
typed, 66%. Every variable in the program got a real Go type, such as
string or map[string]int. The programs that fail this column have at least
one variable the tool could not pin down and fell back to any for. Such code
still compiles and still runs, it is just further from the Go you would have
written. Passing does not mean the types chosen are the best ones, only that
the tool never gave up on one.
emitted, 100%. Valid Go came out: what was written is a real Go file that the language's own parser accepts. This is a floor, not an achievement. A file can parse and still fail to compile.
compiled, 99%. The real Go toolchain built the program, so the code is complete and consistent enough to produce a binary. A program can compile perfectly and be wrong on every line, so this column says nothing about behaviour.
equivalent, 79%. The strongest column, and the one worth reading first. The built Go program was run on the same input as the Perl and printed exactly the same bytes to standard output, exited with the same status, and wrote the same files. The denominator is 228 rather than 271 because tier 4 is judged on honesty instead. What it does not prove: the two agreed on the one input this entry supplies. A translation that goes wrong on input the entry never feeds it passes clean.
honest, 95% of tier 4. Tier 4 holds constructs with no faithful Go equivalent, so there is no correct conversion to compare against. Instead the entry states what the tool must say about it, and the column passes when the conversion report says it. This measures whether the report tells the truth, not whether it tells it well; that is a reading judgment with no column.
A check that could not run counts as skipped and never as a pass. The current run skips none.
Under the table make score prints counts rather than percentages:
- TODOs emitted, 120. Markers left in the generated Go where a human has to finish the job. Each one is explained in the report.
- dynamic fallback, 261 of 4203 symbols (6.2%). How often type inference
gave up on a variable and used
any. This is thetypedcolumn measured per variable rather than per program. - constructs refused, 121. Places the tool declined to translate and said why, in the terms described under "Honesty about what it could not do" above.
- constructs approximated, 1599. Translations that are close rather than exact, each one noted in the report. Perl and Go genuinely disagree about numbers, sorting, iteration order and much else, and an approximation is where that disagreement was crossed deliberately.
- statements that vanished, 0. Statements that produced no code and no note, which is the one wrong this tool promises never to commit. Zero is the only acceptable value.
- programs that panicked, 0. Built programs that stopped early on a Go runtime panic instead of running to the end.
They describe this project's own test corpus. Those programs were written for this corpus by the people who wrote the converter, so it can only ask questions they thought of, and it is script-shaped on purpose because scripts are what the tool is for.
Real installed Perl compiles at a much lower rate. Pointed at 59 modules and scripts already installed on an ordinary Linux system, 58% of the conversions compiled, against 98% for the corpus at the time. Module-heavy code leans on export machinery, object systems and packaging conventions that are mostly out of scope, and the gap between those two numbers is the honest measure of how much the corpus flatters the tool. The short version: an ordinary script converts and runs, a module tree mostly does not, and says why.
Two more things have no number at all. Whether the generated Go reads like Go a person would write is a judgment, not a measurement. And the teaching material, which by this project's own thesis is the point of it, is prose: the test suite checks that every sample in it compiles and prints what it claims, and above that floor the only test is whether a Perl developer actually learns Go from it.
These are real and current, not oversights:
- The session's line editing needs a terminal that supports raw mode, which
covers Linux, macOS and the BSDs. On Windows, and anywhere else without it,
the session falls back to reading whole lines: everything still works, but
the arrow keys, history and
Ctrl-Rdo not, and the session says so once when it starts. Given a pipe rather than a terminal it reads whole lines too, and says nothing, because a transcript should hold the session and not a note about the session. There is no readline dependency to install, because there is no dependency at all. - Coverage is aimed at ordinary script-shaped Perl: scalars, arrays, hashes,
subroutines, references, control flow, regular expressions, string and list
builtins, file reading and writing, command-line options, classes, and the
common CPAN modules whose work the Go standard library does. A package that
blesses a hash reference becomes a struct with methods on a pointer receiver,
@ISAbecomes embedding, and a module in a.pmfile beside the script is converted with it. The constructs it refuses on principle are listed under "Out of scope" below. - Embedding is not inheritance, and the one place that shows is a base class calling a method its subclasses override: Go resolves that call against the base and the override is never reached. Every such call site is reported by name, and the lesson beside it gives the interface-and-composition shape that does work.
- Go's regular expressions are RE2, which has no backreferences and no
lookaround. A pattern using either is refused by name rather than translated
into something that matches different text. The refusal names the feature, the
match site carries a
TODO, and the report says what to write instead. - The generated Go is best effort. It is meant to be read, run and edited,
not trusted blindly. Where the two languages disagree in a way that survives
translation, the report says so; where the tool got something wrong, the Go
compiler and your own reading are the backstop. Expect a straightforward
script to compile and need a little hand-finishing, and expect one built on
nested data structures or code references to need more: those are where type
inference gives up and falls back to
any, and the report says how often it did. - Perl is not required to run this tool, and converting a file never runs
it. Perl is only used by this repository's own test suite, against scripts in
testdata/corpus/.
Decided, not deferred. Issues asking for these will be met with a pointer to this list:
- Module and CPAN-scale conversion. The unit of conversion is a script,
plus any
.pmfiles sitting beside it. Pointing the tool at an installed module tree produces well-labelled refusals, not a port. use overload. An overloaded operator is refused by name, with the method call to write in its place.tie,format/write, typeglobs, source filters,AUTOLOAD,DESTROY,fork/waitpid, and writes through@_aliasing. Each is refused with an explanation of what to write instead. The refusal is the finished feature, not a stopgap.- A backtracking regex engine. Backreferences and lookaround are refused by name rather than approximated into something that matches different text, and the generated project stays dependency-free rather than take on an engine that provides them.
- AI assistance of any kind. A mode that put a locally hosted model in the loop was built, measured and then removed. Asked only to write the Go the converter had refused, under checks that stopped it touching anything else, it filled almost nothing: across 30 real Perl files carrying 346 unconverted sites, a 7B model landed one usable fill and a 9B model landed none, because most gaps in real code are calls into Perl modules that have no Go equivalent. Asked to restyle working code instead, it corrupted one touched program in four. That is a poor return for asking you to install an inference runtime and download several gigabytes, so conversion is deterministic and stays that way. Hosted API providers were never on the table either.
- Reworking a script's Unix assumptions for Windows. Windows gets a binary and the Go the tool writes is ordinary portable Go, but where a script leans on a POSIX shell, on file modes or on process semantics, the translation carries that assumption across rather than inventing a Windows equivalent for it.
make test # the full suite
make test-short # skips the toolchain-heavy tests
make score # runs the corpus and prints the conversion scorecard
make lint # go vet plus a gofmt check
make explain # list the teaching concepts; TOPIC=<id> reads one
make repl # start the interactive session
make repl-demo # pipe a canned session through the repl and check the transcript
make demo # convert a corpus script and run the result
make deps # checks for the system tools the other targets need
make score is the measure of conversion quality, and "What the numbers mean"
above explains every column it prints. It writes the numbers to a file and
prints the change since the previous run, so a diff on that file always means
the conversion moved. ARGS narrows it, so
make score ARGS="-tier tier2 -v" scores one tier and shows every entry.
docs/iterating.md is the guide to improving the conversion: how to read the scorecard, how to pick what to work on, and the rules a change has to respect.
MIT. See LICENSE.