Skip to main content

Rules

Make uses the term rule:

Makefile
target … : prerequisites …
recipe


Fn uses the term... well fn, which is short for function.

fnfile.yml
fns:
<fn>:
do: <steps>

Prerequisites

The following Makefile includes an all rule.

Makefile
build :
echo "building..."

test :
echo "testing..."

all : build test
echo "completed all"
Result
$ make all
building...
testing...
completed all

You can see that all depends on build and test, thus those run before echo "completed all" runs.

In fn there's no distinction between dependencies and other steps.

fnfile.yml
fns:
build:
do:
- sh:
run: echo "building..."

test:
do:
- sh:
run: echo "testing..."

all:
do:
- fn:
name: build
- fn:
name: test
- sh:
run: echo "completed all"
tip

There are syntactic shortcuts that can be made in fn that can make definitions quite concise. For these tutorials, we'll be sticking with the full & explicit syntax in order to prevent possible confusion. You can find more information in the [./api/steps#shortcuts](steps API).

Out of date behavior

The criterion for being out of date is specified in terms of the prerequisites, which consist of file names separated by spaces.

A target is out of date if it does not exist or if it is older than any of the prerequisites (by comparison of last-modification times). The idea is that the contents of the target file are computed based on information in the prerequisites, so if any of the prerequisites changes, the contents of the existing target file are no longer necessarily valid.

This is where make and fn begin to differ significantly, mostly because of goals. make was designed as a tool to help simplify building of c programs. Thus it makes a lot of sense to be "file" oriented (for targets), and to use last-modification timestamps to skip steps when its unnecessary to rerun something.

There is no default behavior in fn to skip fn runs. But there are ways of optimizing.

fnfile.yml
fns:
first:
do:
- sh:
run: echo "first..."

build:
do:
- fn:
name: first
- sh:
run: echo "building..."

test:
do:
- fn:
name: first
- fn:
name: build
- echo "testing..."
Result
$ fn test
first...
first...
building...
testing...

How can we avoid running first multiple times? Introducing...

Memoization

If first is smart enough to know that it will never need to run multiple times, then it can control it's own destiny with...

fnfile.yml
  fns:  
first:
+ memoize: true
do:
- sh:
run: echo "first..."

A memoized function (not to be confused with memorize) "remembers" the results corresponding to some set of specific inputs. Subsequent calls with remembered inputs return the remembered result rather than recalculating it, thus eliminating the primary cost of a call with given parameters from all but the first call made to the function with those parameters.

  • Wikipedia

We'll go over fn inputs and outputs later. This example in particular has no inputs.

Result
$ fn test
first...
building...
testing...
note

📌 TODO Look at memoization + dependency injection as a dynamic way to control this behavior...

Caching

fn doesn't natively perform any caching or checking for changes/timestamps like make does. But that doesn't mean this functionality is out of reach.

fn, like go, aims to provide a "standard library" of tools and step types that are general purpose enough to allow you to express yourself, while still being simple, explicit, and intuitive in nature. The following example is a bit more advanced, so make sure you've had a chance to visit the advanced tutorials first. Please also consult the steps API, and templating API for additional information.

Below is an example of how you can implement caching behavior:

fnfile.yml
fns:
hello:
do:
- with:
cacheKey: '{{ "hello.txt" | Hash1 }}'
cacheFilename: '.fn-cache/cache.json'
cacheAll:
type: map
value: |-
{{- if (io.FileExists (.V "cacheFilename")) }}
{{- os.ReadFile (.V "cacheFilename") }}
{{- end }}
cacheHit:
type: bool
value: '{{ mapHas (.V "cacheAll") (.V "cacheKey") }}'
- return:
if:
cond: '{{ .V "cacheHit" }}'
- sh:
run: sleep 10
- sh:
run: echo "test" > hello.txt
- must: |-
{{- mapSet (.V "cacheAll") (.V "cacheKey") emptyStruct }}
{{- (.V "cacheAll") | encoding.toJson | os.WriteFile (.V "cacheFilename") }}

repeat:
do:
- fn:
name: hello
- fn:
name: hello

Of course, you don't need to repeat this for every fn you write. Here's an example of the decorator pattern:

fnfile.yml
fns:
with-cache:
inputs:
pattern:
type: string
default: '**/*'
do:
type: step
required: true
do:
- with:
filename: '.fn-cache/cache.json'
key: '{{ os.GlobPath (.V "pattern") | LHash1 }}'
all:
type: map
value: |-
{{- if (io.FileExists (.V "filename")) }}
{{- os.ReadFile (.V "filename") }}
{{- end }}
hit:
type: bool
value: '{{ mapHas (.V "all") (.V "key") }}'
- return:
if: '{{ .V "hit" }}'
- tmpl: '{{ .V "do" }}'
- must: |-
{{- mapSet (.V "all") (.V "key") emptyStruct }}
{{- (.V "all") | encoding.toJson | os.WriteFile (.V "filename") }}

hello:
do:
- with:
filename: hello.txt
- fn:
name: with-cache
inputs:
pattern: '{{ .V "filename" }}'
do:
- sh:
run: sleep 10
- sh:
run: echo "test" > '{{ .V "filename" }}'

repeat:
do:
- fn:
name: hello
- fn:
name: hello
tip

Anytime you see a field that accepts one or more step's, you can also use a sh step to get down to a go text-templated shell script.