As I explain in my rant, I have been searching for a replacement to hand-crafted POSIX-make-compatible makefiles to manage my dotfiles (which use symlinks). I decided the simplest thing to do would be to roll my own.


  • written in a language likely to be immediately available on major platforms (I have chosen Perl)
  • no other external dependencies (installation for dotfiles should be a clone-and-run process)
  • can live next to/as a submodule of a dotfiles repository

For this I developed Plink. I will quote the specification from the README, and omit the same from the POD in my Perl code. Full details at the link above, including test code.


The Plink module provides an implementation of the Plink language, a DSL built to describe dotfiles via symlinks. The language is a strict superset of make(1); implementations produce (POSIX) makefiles for use. It is important to note that Plink only guarantees that generated code is POSIX-compatible. User-written code may use GNU make or other non-portable features. What follows is the canonical specification of the Plink language.

Plink Specification

Plink is superset of POSIX make. It defines four (4) new syntaxes with semantic meaning.

Plink also defines a header and footer.

A conforming implementation transforms the input file (or STDIN) as specified by the syntaxes. All lines not participating in one of these syntaxes, in addition to # comments, are copied verbatim (this includes blank lines). The output is written to a file named by the environment variable PLINK_OUTPUT, or Makefile if unset.

See t/test.plink for an example Plink file, and t/expected.mk for its output.

Square Brackets [ prerequisites ]

Any line like

target names [ prerequisites ] ...

will be transformed to

target names: $MAKEFILE prerequisites ...

$MAKEFILE denotes the name of the generated filename. The spaces around [] are not required, but often recommended. ... may make use of any the bang-syntaxes.

Bang and Double-bang ! !!

Lines like

target ! commands

will be transformed to


The list commands lasts to the end of the line. commands will be indented by one tab, as make requires.

Similarly, lines like

target !!
on more lines


<TAB>on more lines

The list commands lasts until the line containing exactly !!. Commands will be tab-indented.

Symlink (Fat-arrow) <=

The meat of the DSL. Lines like

link_in_home <= dotfile_under_$(LINKS)

Become part of a mapping. The output creates dependencies of the form

$(HOME)/link_in_home: $(LINKS)dotfile_under_$(LINKS)

for each fat-arrow, and also gives each the recipe

if test -e $@ ; then rm -rf $@ ; fi
ln -s $$(realpath $?) $@
@echo $@ '->' $$(realpath $?)

which creates the link. Finally, a target named symlink is provided which depends on all the link_in_homes provided: it is considered the public API for any make target that wishes to depend on symlink-generation.

Dotfiles are files under the make macro $(LINKS). Due to the generation rule, if $(LINKS) is not set, the current directory is used. This can be useful to put all dotfiles under a directory named, e.g., dots. Then you want to include a line in your Plink file like

LINKS = dots/

(note the trailing slash).

The use of the macro SYMLINKS is considered a Plink implementation detail and is subject to change; users who set SYMLINKS or depend on it's effects are invoking undefined behavior.


The Plink header consists of

SHELL = /bin/sh


The Plink footer consists of the symlink target implementation and the following:

<TAB>$$(realpath $?)

MAKEFILE refers to the generated output, and INPUT to the Plink file used as input.


plink $infname, $outfname

Implements the Plink specification, transforming the file named infname to outfname. Handles for STDIN and STDOUT are accepted, though STDIN has the side-effect that the Makefile rules now depend on the literal file STDIN. Similarly, STDOUT breaks the rule to make the generated Makefile.


#! /usr/bin/env perl

package Plink v1.0.0;

use v5.10;
use strict;
use warnings;
use autodie;

use open ':locale';
# implicit with
# use v5.10;
use feature 'unicode_strings';

use Exporter 'import';
our @EXPORT_OK = qw(plink);

main() unless caller(0);

sub trim {
    my $string = shift;
    $string =~ s/^\s+|\s+$//g;
    return $string;

sub process_deps {
    my ($line, $outfname) = @_;
    return $line if $line =~ /^#/;
    my ($target, $deps, $rest) = $line =~ m{^
        (.+)        # target
        \[(.*)\]    # dependencies
        (.*)        # anything else
    return $line unless $target;
    $target = trim $target;
    $rest = trim $rest;
    my @deps = grep(!/^\s*$/, map { trim $_ } (split /\s+/, $deps));
    return "$target: $outfname @deps $rest\n";

sub process_bang {
    my ($line, $outfname) = @_;
    $line = process_deps($line, $outfname);
    my ($target, $rest) = $line =~ m{^
        ([^!]+)    # target
        !          # bang
        ([^!]+)    # rest
    return unless $target;
    $rest = trim $rest;
    $target = trim $target;
    unless ($target =~ /:/) {
        $target = "$target:"
    return <<TARGET;

sub print_header {
    my $out = shift;
    print $out <<HEADER;
SHELL = /bin/sh


sub print_footer {
    my ($out, $outfname, $infname) = @_;
    print $out <<FOOTER;
# symlink: ensure symlinks created
symlink: $outfname \$(SYMLINKS)

\tif test -e \$@ ; then rm -rf \$@ ; fi
\tln -s \$\$(realpath \$?) \$@
\t\@echo \$@ '->' \$\$(realpath \$?)

$outfname: $infname
\t\$\$(realpath \$?)

sub process_lines {
    my ($in, $out, $outfname) = @_;
    my %links;

    while (my $line = <$in>) {
        next if ($line =~ m/^#!|^!!$/);
        # skip comments, no preprocessing
        if ($line =~ m/^#/) {
            print $out $line;
        # !!
        elsif ($line =~ m/!!/) {
            (my $target = process_deps($line, $outfname)) =~ s/!!\s*$//;
            $target = trim $target;
            unless ($target =~ /:/) {
                $target = "$target:"
            print $out "$target\n";
            while (my $sub_line = <$in>) {
                last if $sub_line =~ /!!/;
                print $out "\t$sub_line";
        # !
        elsif ($line =~ m/!/) {
            print $out process_bang($line, $outfname);
        # <=
        elsif ($line =~ m/<=/) {
            my ($link, $dotfile) = map { trim $_ } split /<=/, $line;
            $links{$link} = $dotfile;
        else {
            print $out process_deps($line, $outfname);

    return %links;

sub print_links {
    my ($out, %links) = @_;
    print $out "SYMLINKS = \\\n";
    # print %links
    for my $link (sort keys %links) {
        print $out "\$(HOME)/$link \\\n";
    print $out "\n\n";

    for my $link (sort keys %links) {
        print $out "\$(HOME)/$link: \$(LINKS)$links{$link}\n";
    print $out "\n";

sub get_in {
    my $infname = shift;
    if ($infname eq \*STDIN) {
        return ($infname, 'STDIN');
    else {
        open(my $in, '<', $infname);
        return ($in, $infname);

sub get_out {
    my $outfname = shift;
    if ($outfname eq \*STDOUT) {
        return ($outfname, 'STDOUT');
    else {
        open(my $out, '>', $outfname);
        return ($out, $outfname);

sub plink {
    my ($infname, $outfname) = @_;
    my ($in, $out);
    ($in, $infname) = get_in $infname;
    ($out, $outfname) = get_out $outfname;

    print_header $out;
    my %links = process_lines $in, $out, $outfname;
    print_links $out, %links;
    print_footer $out, $outfname, $infname;


sub main {
    my $output = $ENV{PLINK_OUTPUT} // 'Makefile';
    my $input = shift @ARGV // \*STDIN;
    plink $input, $output;

# return true
  • 1
    \$\begingroup\$ in a couple of places you check for emptiness before calling trim (which returns an empty value if the input is only whitespace), and then process the possibly-empty value as normal. Is that intended? \$\endgroup\$ Jul 18, 2019 at 6:43
  • \$\begingroup\$ It should be—trimming rest, it doesnt matter if its empty (it’s an optional part, and may not be there to begin with). If youre referring to trimming target, i hadnt thought about that. I dont do a whole lot of error checking. Garbage in, garbage out? \$\endgroup\$ Jul 18, 2019 at 12:34


Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.