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.
Requirements:
- 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.
DESCRIPTION
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
target:
<TAB>commands
The list commands
lasts to the end of the line. commands
will be indented
by one tab, as make requires.
Similarly, lines like
target !!
commands
on more lines
!!
become
target:
<TAB>commands
<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_home
s 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.
Header
The Plink header consists of
SHELL = /bin/sh
.SUFFIXES:
Footer
The Plink footer consists of the symlink target implementation and the following:
MAKEFILE: INPUT
<TAB>$$(realpath $?)
MAKEFILE
refers to the generated output, and INPUT
to the Plink file used
as input.
METHODS
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.
CODE
#! /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
$}x;
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
$}x;
return unless $target;
$rest = trim $rest;
$target = trim $target;
unless ($target =~ /:/) {
$target = "$target:"
}
return <<TARGET;
$target
\t$rest
TARGET
}
sub print_header {
my $out = shift;
print $out <<HEADER;
SHELL = /bin/sh
.SUFFIXES:
HEADER
}
sub print_footer {
my ($out, $outfname, $infname) = @_;
print $out <<FOOTER;
# symlink: ensure symlinks created
symlink: $outfname \$(SYMLINKS)
\$(SYMLINKS):
\tif test -e \$@ ; then rm -rf \$@ ; fi
\tln -s \$\$(realpath \$?) \$@
\t\@echo \$@ '->' \$\$(realpath \$?)
$outfname: $infname
\t\$\$(realpath \$?)
FOOTER
}
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;
close($out);
close($in);
}
sub main {
my $output = $ENV{PLINK_OUTPUT} // 'Makefile';
my $input = shift @ARGV // \*STDIN;
plink $input, $output;
}
# return true
1;
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\$rest
, it doesnt matter if its empty (it’s an optional part, and may not be there to begin with). If youre referring to trimmingtarget
, i hadnt thought about that. I dont do a whole lot of error checking. Garbage in, garbage out? \$\endgroup\$