Ein literate programming Werkzeug.

Programmübersicht

Motivation

Programme sind festgehaltene Ideen. Die zugrunde liegenden Konzepte sind nicht an irgendeine Syntax sodern an Gedanken und Modelle gekoppelt. Dieses Programm soll dem Entwickler helfen sich wieder auf diese Konzepte konzentrieren zu können statt von einer vorgegebenen Struktur behindert zu werden.

Das bringt verschiedene Vorteile mit sich:

  • Die Konzepte stehen stärker im Vordergrund und grundlegende Architekturfehler fallen viel schneller auf.

  • Programme werden für andere (und auch für den Autor selbst) erfassbarer und die Dokumentation deutlich besser.

  • Programme werden spannender.

  • Es regt zu mehr Kreativität an.

Gleichzeitig gibt es auch eine ganze Menge Probleme mit diesem Programmierkonzept. Diese behandle ich in einem späteren Abschnitt und versuche Lösungsansätze dafür zu finden.

Architektur

Dieses Programm existiert nicht unabhängig von seinem Kontext. Es ist dazu gedacht asciidoc Texte in Programmcode und in Programmdokumentation umzuwandeln. Letzteres ist nicht schwer, denn das ist ja ohnehin die Hauptaufgabe von asciidoc. Mit Asciidoctor besteht bereits ein hervoragendes Programm zu diesem Zweck. Da ich aber rust besser kennenlernen will und mit asciidoctrine einen eigenen asciidoc Interpreter in rust geschrieben habe, habe ich mich entschlossen lisi als asciidoctrine-extension zu implementieren.

use asciidoctrine::*;
asciidoctrine = { path = "../asciidoctrine", version = "0.1" }
Diagram
Figure 1. Aufbau von lisi

lisi arbeitet direkt auf einem asciidoc AST und erzeugt aus den darin enthaltenen Quelltext-Snippets (TODO link) Sourcecode Dateien (und manchmal auch weitere Dateien). Im AST fügt es einige zusätzliche Informationen hinzu (vor allem Referenzen, wo Code-Snippets verwendet werden TODO interner link).

Die Hauptdatei hat dabei folgendes Gerüst:

src/lib.rs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<<required_crates>>

<<internal_modules>>

<<crate_usages>>

<<internal_structs|join="\n\n">>

pub struct Lisi {
  <<lisi_internal_variables>>
}

impl Lisi {
  pub fn new() -> Self {
    Lisi {
      <<lisi_init_variables>>
    }
  }

  <<internal_functions|join="\n\n">>

  /// Gets all snippets from the ast
  pub fn extract_ast(&mut self, input: &AST) -> Result<SnippetDB, Error> { (3)
    let snippets = SnippetDB::new();

    // extract snippets from all inner elements
    input.elements.iter().try_fold(snippets, |snippets, element| {
      self.extract(snippets, element)
    })
  }

  /// Build all snippets (Runs the vm)
  pub fn generate_outputs(&mut self, snippets: SnippetDB, ast: &AST) -> Result<(), Error> { (4)
    <<generate_outputs>>

    Ok(())
  }
}

impl Extension for Lisi { (1)
  fn transform<'a>(&mut self, input: AST<'a>) -> anyhow::Result<AST<'a>> { (2)
    let snippets = self.extract_ast(&input)?; (3)

    self.calculate_snippet_ordering(&snippets); (5)

    self.generate_outputs(snippets, &input)?; (4)

    Ok(input)
  }
}
1 Da lisi eine asciidoctrine Erweiterung ist, implementiert das Programm die dafür erforderliche Schnittstelle.
2 Als erste Funktion aller Erweiterungen wird immer die Funktion transform aufgerufen. Sie bekommt den von asciidoctrine vorverarbeiteten AST sowie eventuell vorhandene Argumente übergeben. Sie übernimmt diesen und gibt hinterher eine modifizierte Version des ASTs zurück (welche dann weiterverarbeitet werden kann).
3 Die grundlegende Aufgabe zu Beginn der Transformation ist das Extrahieren des Quellcodes aus der Datei.
4 Zum Schluss können alle Dateien generiert und Scripte ausgeführt werden.
5 Oftmals ist die Reihenfolge der Abarbeitung der Code-Schnipsel entscheidend. Diese wird vor der Abarbeitung festgelegt.

Benutzung

Generelle Herangehensweise

Beim schreiben eines literate Programmes sollte man wie bei einer wissenschaftlichen Arbeit vorgehen:

  • Zunächst schreibt man eine Übersicht mit der Ausgangslage, der Motivation und einer groben Zusammenfassung des eigenen Lösungsansatzes.

  • Es ist gut sich frühzeitig Gedanken über verschiedene Lösungsalternativen zu machen und diese gegeneinander abzuwägen (Das kann man auf jeder Ebene des Programms tun. Sowohl bei der Architektur als auch bei Details)

    • Diesen Alternativen kann man einen eigenen Abschnitt oder ein eigenes Kapitel widmen. Sobald mit der Umsetzung des Programms begonnen wird sollten sie recht weit nach hinten wandern, da sie für die meisten Benutzer nicht relevant sind.

  • Dann sollte man mit der Bedienung beginnen. So hat man eine User orientierte Herangehensweise (eine Art User Story) und kann von dort aus leicht die Requirements und darauf aufbauend die Unit Tests festhalten.

    • Sollte das Programm größer werden, ist es gut alle weniger offensichtlichen Unittests (Corner Cases) nach hinten in ein eigenes Kapitel zu verschieben und einen Link dorthin bereitzustellen.

  • Dann kommt das Kapitel mit der eigentlichen Implementierung.

  • Bei vielen Programmen wird es nützlich sein Beispiele (als eine Art Tutorial) bereitzustellen.

Zu Beginn kann man mit einem einzigen Dokument starten aber im Laufe der Zeit wird es bei größeren Projekten gut sein, sie in Kapitel (Module) zu gliedern und diese in ein Hauptdokument einzubinden.

Die Reihenfolge des Schreibens kann sich überlagern (obwohl es gut ist mit der Übersicht und den grundlegenden Fragen zu beginnen) aber wahrscheinlich ist die Anordnung der Kapitel im endgültigen Dokument immer ähnlich. Im Laufe der Entwicklung wird man immer mal wieder aufräumen und umstrukturieren müssen (refaktoring).

Es ist wichtig eine Begründung für alle Designentscheidungen aufzuschreiben damit man bei der späteren Pflege des Programmes weiß, ob diese noch gültig oder obsolet sind. Das ermöglicht auch bei der gemeinsamen Arbeit mit einem Team an einem Projekt, eine Argumentationsgrundlage für Designentscheidungen/Änderungen zu haben.

Quellcode generieren

Extrahieren

Die normalen Quellcode Listings können gebraucht werden, um ein Programm zu erstellen.

Fliestext ... (3)

[[ID]] (2)
[source, lua]
.Überschrift
----
Quelltext ... (1)
----

Fliestext ... (3)
1 lisi kümmert sich nur um Quelltext-Snippets.
2 Die ID (anchor) kann benutzt werden, um Code-Snippets zu referenzieren.
3 Der restliche Text wird von dem Programm ignoriert.

Zusammenfügen

Die verschiedenen Codeschnipsel kann man in anderen Codeschnipseln einbinden. Dafür verwendet man einfach eine cross reference auf den anchor des jeweiligen Schnipsels:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
We need the testmodule for this project.

[[sample1_required_modules]] (1)
[source, lua]
----
require "testmodule"
----

This is the importing file. We could print out the version.

[source, lua, save]
.sample1.lua
----
<<sample1_required_modules>> (2)

print(testmodule.version)
----
1 Der Codeschnipsel bekommt eine ID (anchor)
2 Hier wird der obere Codeschnipsel über eine cross reference in diesen eingebunden.

Das Ergebnis wäre eine Datei:

sample1.lua
require "testmodule"

print(testmodule.version)

Die Reihenfolge ist dabei egal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
First we give a short outline of the program. It imports the required modules
and then prints out its version.

[source, lua, save]
.sample2.lua
----
<<sample2_required_modules>>

print(testmodule.version)
----

We need the testmodule for this project.

[[sample2_required_modules]]
[source, lua]
----
require "testmodule"
----

In diesem Beispiel haben wir den Schnipsel sample2_required_modules erst nach dem importierenden Schnipsel geschrieben. Die Ausgabe bleibt aber die gleiche:

sample2.lua
require "testmodule"

print(testmodule.version)

Außerdem kann man einen Codeschnipsel beliebig oft in einem oder mehreren anderen Codeschnipseln einfügen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Lets assume we want to use the following snippet in multiple places.

[[sample3_multiple]]
[source, lua]
----
require "testmodule"
----

Than we could import it in the same snippet multiple times.

[source, lua, save]
.sample3-1.lua
----
<<sample3_multiple>>

print(testmodule.version)

<<sample3_multiple>>
----

And we could even use it again in another snippet.

[source, lua, save]
.sample3-2.lua
----
<<sample3_multiple>>

print(testmodule.version .. "my other snippet")
----

In diesem Fall würden die folgenden beiden Dateien generiert.

sample3-1.lua
require "testmodule"

print(testmodule.version)

require "testmodule"
sample3-2.lua
require "testmodule"

print(testmodule.version .. "my other snippet")

Verwenden zwei (oder mehr) Schnipsel den gleichen anchor, so wird der Inhalt in der Reihenfolge, in der die Schnipsel im Quelltext erscheinen, aneinandergefügt. Auf diese Weise kann man leicht Erklärungen in einen Quelltext einfügen oder an verschiedenen Stellen Ergänzungen zu einem Codebereich hinzufügen (z.B. die Imports erweitern).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
We do some thing in our code.

[source, lua, save]
.sample4.lua
----
<<some_process>>

print(result_of_someprocess)
----

To do this we need to do something with a variable.

[[some_process]]
[source, lua]
----
variable = 42
variable = variable + 42
----

But something else has also to be done. For example we need to set the
result.

[[some_process]]
[source, lua]
----
result_of_someprocess = variable * variable
----

Now lets go on to another thing ...

In der Ergebnisdatei sind nun die beiden Schnipsel hintereinandergehängt.

sample4.lua
variable = 42
variable = variable + 42
result_of_someprocess = variable * variable

print(result_of_someprocess)

Nicht immer möchte man einfach einen Zeilenumbruch zwischen den Snippets haben. Manchmal ist es z.B. schöner eine Leerzeile zu haben. Bei Aufzählungen ist oft ein Komma das beste.

In diesem Fall kann man an der einfügenden Stelle festlegen, welche Trennzeichen einem am besten gefallen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Let's imagine we need some rust struct.

[[mystruct]]
[source, rust]
----
pub struct MyStruct { <<mystruct_fields|join=", ">> }
----

In our main process we need to define the struct and initialize it.

[source, rust, save]
.sample5.rs
----
<<mystruct>>

impl MyStruct {
  pub fn new {
    MyStruct {
      <<init_fields|join=",\n">>
    }
  }
}
----

In our struct we have variable x

[[mystruct_fields]]
[source, rust]
----
x: String
----

And we initialize it properly

[[init_fields]]
[source, rust]
----
x: "this is the x text".to_string()
----

Now we can talk about all the functions that use x...

After some time we may have a function that use some other variable y.

[[mystruct_fields]]
[source, rust]
----
y: u8
----

And how is it initialized? You know the answer:

[[init_fields]]
[source, rust]
----
y: 42
----

And so on ...

Das Ergebnis ist eine Datei, die die beiden Snippet-Listen unterschiedlich zusammenfügt.

sample5.rs
pub struct MyStruct { x: String, y: u8 }

impl MyStruct {
  pub fn new {
    MyStruct {
      x: "this is the x text".to_string(),
      y: 42
    }
  }
}

Es lohnt sich nicht immer einen eigenen Block anzulegen. Bei kurzen Snippets kann es praktischer sein ein Inline Code Objekt zu verwenden. So könnte man den vorigen Quelltext auch folgendermaßen schreiben:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Let's imagine we need some rust struct.

[[mystruct]]
[source, rust]
----
pub struct MyStruct { <<mystruct_fields|join=", ">> }
----

In our main process we need to define the struct and initialize it.

[source, rust, save]
.sample5.rs
----
<<mystruct>>

impl MyStruct {
  pub fn new {
    MyStruct {
      <<init_fields|join=",\n">>
    }
  }
}
----

In our struct we have variable [[mystruct_fields]]`x: String`. And we
initialize it properly

[[init_fields]]
[source, rust]
----
x: "this is the x text".to_string()
----

Now we can talk about all the functions that use x...

After some time we may have a function that use some other variable
[[mystruct_fields]]`y: u8`. And how is it initialized? You know the
answer:

[[init_fields]]
[source, rust]
----
y: 42
----

And so on ...

Wird eine cross reference im Quelltext eingerückt, so wird der ganze importierte Quelltext ebenfalls um die gleiche Höhe eingerückt (im Grunde wird vor jedem Zeilenbeginn der Text vor der cross reference wieder eingefügt).

Eingerückte Snippets
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Imagine you want to print a long pattern of "//***//" around so text to emphasize it.

We can do this:

[[print_pattern_once]]
[source, c]
----
printf("/");
printf("***");
printf("/");
----

But we want this line to be long

[[print_pattern]]
[source, c]
----
for (i=0;i<5;i++) {
  <<print_pattern_once>>
}
print("\n");
----

And now lets emphasize the text.

[source, c, save]
.sample7.c
----
<<print_pattern>>
print("My emphasized text!!\n"
<<print_pattern>>
----

Die generierte Datei wäre folgende:

sample7.c
for (i=0;i<5;i++) {
  printf("/");
  printf("***");
  printf("/");
}
print("\n");
print("My emphasized text!!\n"
for (i=0;i<5;i++) {
  printf("/");
  printf("***");
  printf("/");
}
print("\n");

Will man das vermeiden, so kann man das Stichwort inline angeben (TODO wirklich? oder soll man in diesem Fall den Schnipsel einfach anders schreiben? Was ist mit dem Zeilenende hinter der cross reference? Manchmal wäre es gut es jedesmal hinten anzuhängen, manchmal nur einmal zu lassen und manchmal gar nicht einzufügen.)

Will man einen den generierten Text in eine Datei speichern, so kann man den Dateinamen angeben.

TODO Quellcodebeispiele zwischen jedem Absatz

Manchmal möchte man keine Ersetzung der cross reference in einem Snippet haben. In diesem Fall kann man das Attribut lisi-raw übergeben und der Snippet bleibt unangetastet.

TODO Codebeispiel

Parameterierte Snippets

Manche Schnipsel sind sehr allgemein und haben eine vielfältige Verwendung. Mit parameterisierten Schnipseln kann man Bibliotheken anlegen, welche eine breitere Verwendung von Schnipseln erlauben.

Dazu kann man einfach die Platzhalter überschreiben. Zum definieren eines Parameters wird := verwendet.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
There is a snippet we want to use in different contexts.

[[test_condition]]
[source, sh]
----
if [[ <<condition>> ]] then
  echo "<<err_message| condition:=<<condition>> >>"
  exit <<exit_code>>
fi
----

Normally we exit with the message
[[err_message]]`the condition <<condition>> was not met` and return exit
code [[exit_code]]`1`.

Now we can use this snippet to test some condition before we execute our
script. Lets say we want to make sure `file_xy.txt` exists.

[[checks]]
[source, sh]
----
<<test_condition| condition:="-f file_xy.txt">>
----

But we could also override the default snippets with a custom one. For
example to change the error message.

[[checks]]
[source, sh]
----
<<test_condition|condition:="-f file_yz.txt",
                 err_message:="my custom err message yz">>
----

It's also possible to nest snippets. We can just reference them. Let's
say we would like to return the message
[[custom_err_message]]`return from nested param snippet with code <<custom_exit_code>>`
and exit code [[custom_exit_code]]`42`.

[[checks]]
[source, sh]
----
<<test_condition|condition:="-f nested_params.txt",
                 err_message:=<<custom_err_message>>,
                 exit_code:=<<custom_exit_code>>>>
----

What about more deeply nested snippets? Let's say we have another
condition where we want something to be done when it is met instead of
finishing the program.

[[process_condition]]
[source, sh]
----
if [[ <<condition>> ]] then
  echo "<<info_message| condition:=<<condition>> >>"
  <<do_something>>
fi
----

As the default info we put out
[[info_message]]`the condition <<condition>> was met` and call a
function [[do_something]]`myfunc($1)`.

[[checks]]
[source, sh]
----
<<process_condition| condition:="-f $1">>
----

But now suppose we want to process a certain function if `$2` exists but
when [[inner_condition]]`-f $1` matches too we want to do something additionally.

In this case we have to nest our snippets at a deeper level.

[[checks]]
[source, sh]
----
<<process_condition|condition:="-f $2",
    do_something := <<deep_nested_snippet|
      inner_do_something := "nestedfunc($1, $2)"
      >>
    >>
----

[[deep_nested_snippet]]
[source, sh]
----
<<process_condition|
    condition:="test_default_condition($2)" >>
<<process_condition|
    condition:=<<inner_condition>>,
    do_something:=<<inner_do_something>> >>
----

Now we put all of these conditions at the beginning of our script.

[source, sh, save]
.sample8.sh
----
<<checks>>

echo "you passed all checks"
----

Das hier erzeugte Script sähe dann folgendermaßen aus:

sample8.sh
if [[ -f file_xy.txt ]] then
  echo "the condition -f file_xy.txt was not met"
  exit 1
fi
if [[ -f file_yz.txt ]] then
  echo "my custom err message yz"
  exit 1
fi
if [[ -f nested_params.txt ]] then
  echo "return from nested param snippet with code 42"
  exit 42
fi
if [[ -f $1 ]] then
  echo "the condition -f $1 was met"
  myfunc($1)
fi
if [[ -f $2 ]] then
  echo "the condition -f $2 was met"
  if [[ test_default_condition($2) ]] then
    echo "the condition test_default_condition($2) was met"
    myfunc($1)
  fi
  if [[ -f $1 ]] then
    echo "the condition -f $1 was met"
    nestedfunc($1, $2)
  fi
fi

echo "you passed all checks"
Details und Edge-Cases

Das funktioniert auch, wenn die Snippets eingerückt wurden

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
We have a snippet

[[inner_snippet]]
[source, yaml]
----
this is my snippet <<text>>
----

And we indent it

[source, yaml, save]
.sample.yaml
----
category:
    <<inner_snippet|
        text:="my param text">>

category2:
    <<inner_snippet|
        text:=<<referenced_param>> >>
----

And here we have a param we reference

[[referenced_param]]
[source, yaml]
----
referenced param text
----

Die erzeugte Datei ist folgende:

sample.yaml
category:
    this is my snippet my param text

category2:
    this is my snippet referenced param text

In einem Snippet kann man Parameter definieren, die in einem aufrufenden Snippet überschrieben werden. Manchmal will man dabei auch in einem als Parameter übergebenen Snippet (Unter)parameter überschreiben. Das muss immer explizit geschehen, denn sonst könnten in einem Snippet Parameter überschrieben werden, von denen ein aufrufendes Snippet einige Ebenen höher gar keine Ahnung hat. Da das ein bisschen schwer zu verstehen ist, schauen wir uns ein Beispiel an:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
We have a basic snippet

[[snippet_with_param]]
[source, python]
----
print("<<echo_text>>")
----

And we have a snippet that uses it.

[[calling_snippet_one]]
[source, python]
----
def my_function():
  <<snippet_with_param>>
  # <<echo_text>>
----

We have onther snippet that uses it too. But it overwrites the parameter.

[[calling_snippet_two]]
[source, python]
----
def my_other_function():
  <<snippet_with_param| echo_text := <<echo_text>> >>
  # <<echo_text>>
----

When we use these snippets we expect them to do different things. The
first should have the nested inner snippet untouched and the second
should use the parameter in the nested snippet.

[source, python, save]
.nested.py
----
<<calling_snippet_one|
    echo_text:="touch only outer">>

<<calling_snippet_two|
    echo_text:="touch inner snippet too">>
----

The default text is [[echo_text]]`untouched`.

Die generierte Datei ist:

nested.py
def my_function():
  print("untouched")
  # touch only outer

def my_other_function():
  print("touch inner snippet too")
  # touch inner snippet too

Man kann auch eine Art Aufruf-Hierarchie machen, indem man parametrisierte Snippets als Parameter einsetzt. Um das zu erklären gehen wir nochmal auf das Beispiel einer yaml Konfigurationsdatei ein.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
We have a snippet

[[inner_snippet]]
[source, yaml]
----
this is my snippet <<text>>
----

And we indent it

[[category_template]]
[source, yaml]
----
category:
    <<inner_snippet|
        text:="my param text">>

category2:
    <<value_category2>>
----

Normally our `value_category2` is like this

[[value_category2]]
[source, yaml]
----
<<inner_snippet|
    text:=<<referenced_param>> >>
----

And here we have a param we reference

[[referenced_param]]
[source, yaml]
----
referenced param text
----

But in the end we want to use our `category_template` twice. Once in the
normal way and once with a substtuted inner snippet.

[source, yaml, save]
.sample.yaml
----
<<category_template>>

<<category_template|
    value_category2:=<<inner_snippet|
      text:="substituted with a param inside a param">> >>
----

Die erzeugte Datei ist folgende:

sample.yaml
category:
    this is my snippet my param text

category2:
    this is my snippet referenced param text

category:
    this is my snippet my param text

category2:
    this is my snippet substituted with a param inside a param

Transformieren

Vorhandene Codeschnipsel können nicht nur zu einer größeren Einheit zusammengesetzt werden, sondern auch manipuliert werden. Auf diese Weise kann man eine Art Templates generieren um damit dynamisch angepasste Texte zu erzeugen. Anwendungen wären z.B. Serienbriefe oder die Ergänzung eines Lizenz-Headers in allen Quellcode Dateien.

Die zu diesem Zweck bereitgestellten Funktionen werden jetzt erklärt:

save (Speichern)

Um überhaupt ein ausführbares Programm zu erhalten ist es unerlässlich den erzeugten Quellcode in ein tatsächliches Programm umwandeln zu können. Die wichtigste Möglichkeit dazu ist einen Schnipsel in eine Datei abspeichern zu können. Dazu wird das Attribut save verwendet:

Lets create a "hello world" program.

[source, lua, save]
.hello.lua
---
print("Hello World")
---
eval (Ausführen)

Eine weitere Methode das Programm zu nutzen ist es direkt auszuführen. Das wird mit dem Atrribut eval gemacht.

Lets run a "hello world" program.

[source, lua, eval]
.hello.lua
---
print("Hello World")
---

Dieses Beispiel würde direkt "Hello World" auf der Konsole schreiben.

Als Interpreter verwendet lisi standardmäßig die angegebende Scriptsprache (in den meisten Fällen stimmen der Name der Sprache und der Name des Interpreter-Executables überein).

pipe

Manchmal möchte man einen Codeschnipsel in leicht modifizierter Form vielfach verwenden. In diesem Fall ist pipe ein sehr mächtiges Werkzeug.

Wird pipe als Attribut an einen Code Block angehangen, wird der darin befindliche Code, wie bei eval, ausgeführt. Im Gegensatz zu eval hat pipe die Möglichkeit die Code-Schnipsel selbst zu manipulieren. Dazu bekommt es eine Variable lisi zu Verfügung gestellt, welche Zugriff auf die Code-Generierung erhält.

Die Sprache des pipe Interpreters ist rhai.

Print out the doctument header when running the program.

[source, rhai, pipe]
---
lisi.store("print_header", [[=[print("${doc.header}")]]=])
---

Damit ist pipe ein äußerst mächtiges Werkzeug da man beliebig komplexe Programme benutzen kann um Code Schnipsel zu erzeugen. Alle Methoden zum Transformieren und Zusammenfügen lassen sich auch mit pipe verwenden, so dass man sogar mit pipe erzeugte Codeschnipsel verwenden könnte um neue pipe Codeschnipsel zu erzeugen.

Folgende Funktionen werden von der lisi Variable zur Verfügung gestellt:

store(name, schnipsel)

Speichert einen String unter einem Namen als Schnipsel ab.

map(liste, function)

Führt eine Funktion über eine Liste von Objekten aus.

save(path, schnipsel)

Führt den save Befehl auf einem String aus. Dieser wird unter dem Pfad path abgespeichert.

eval(schnipsel, interpreter)

Führt den eval Befehl auf einem String aus. Der String wird von dem übergebenen interpreter ausgeführt (Standard ist lua).

pipe(schnipsel_name, parameter)

Führt einen pipe Befehl auf einem anderen Schnipsel aus.

Einige Variablen sind immer stehen ebenfalls immer zur Verfügung:

doc

Der ursprüngliche AST, welcher an die Erweiterung übergeben wird.

args

Die Kommandozeilenparameter, die beim Aufruf zur Verfügung standen.

rawsnippets

Die Codeschnipsel, wie sie aus dem AST extrahiert wurden, bevor die inneren Referenzen durch Schnipsel ersetzt wurden.

snippets

Die Codeschnipsel mit bereits eingesetzten Schnipseln an den Referenzen.

Die Anwendungsmöglichkeiten von pipe sind extrem vielfältig und mächtig. Deshalb werden wir in den nächsten Abschnitten einige Anwendungsfälle besprechen, die sich mit pipe elegant lösen lassen.

Unit Tests mit pipe

TODO kann auch einen link auf die Benutzer Dokumentation Beschreibung enthalten

Aspect oriented Programming mit pipe

Aspect oriented Programming ist ein Programmieransatz welcher die Modifikation von bestehenden Modulen erlaubt. Das ist oftmals sehr problematisch und führt (meiner Meinung nach) bei breitem Einsatz zu schlecht wartbarem und undurchschaubaren Quellcode. Wird es allerdings sparsam eingesetzt kann es manchmal sehr schwierige Probleme einfach lösen.

Ein Beispiel sind Bedingungen an einen Typ welche zur Einführung des Typs noch nicht klar sind. In Rust möchte man z.B. oft Typen mit einem derive Macro automatisch Funktionen implementieren lassen (z.B. serde). Zum Zeitpunkt der Definition des Typs ist diese Notwendigkeit aber noch gar nicht klar oder nicht offensichtlich. Dies führt zu Problemen in der Erklärung (welche bei einem Literate Programm ja immer das wichtigste überhaupt ist) da man anmerken muss, dass diese Code Stelle später noch erklärt wird und in der späteren Erklärung den Quellcode nicht sieht. …​

TODO

Macro Systeme mit pipe

Ein pipe Block kann natürlich selber Snippets importieren. Dadurch ist es möglich Funktionen zu definieren (sogar in einem anderen Dokument) und diese später in verschiedenen pipe Schnipseln zu benutzen.

TODO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
First we define our macro lib:

[[pipe_functions]]
[source, rhai]
---
fn mystuff(string) {
  // Do my fancy manipulation functions here
}
---

Then we can use it in our pipes

[source, rhai, pipe]
---
<<pipe_functions>>

lisi.store("out", mystuff("test"));
---

We can also use this functions in other snippets and even in other documents.
Den Ausführungsfluss steuern

Manchmal ist es wichtig, die Reihenfolge, in der die Funktionen ausgeführt werden, festlegen zu können. Ist die Reihenfolge nicht explizit definiert kann die Implementierung die save,eval,pipe etc Funktionen in einer beliebigen Reihenfolge oder sogar paralell ausführen. Oftmals ist das gut aber in einigen Fällen möchte man die Reihenfolge explizit festlegen. Hier einige Beispiele:

  • Wenn man ein Script mit save speichern will und genau danach dieses Script in einem eval Schritt mit Parametern aufrufen möchte. In diesem Fall muss der save Schritt vor eval ausgeführt werden. So einen Anwendungsfall hat man oft bei build-, deploy-, und bootstrap Schritten.

  • Den umgekehrten Fall gibt es genauso: Man möchte mit save Snippets einbinden, diese sollen aber noch in einem pipe Schritt generiert werden.

  • Manchmal hat man pipe Schritte, die wiederrum von generierten Snippets (durch andere pipe Schritte) abhängen.

Um diese und weitere Anwendungsfälle zu ermöglichen sind hier ein paar grundlegende Regeln und Attribute definiert:

Sobald ein Snippet ein anderes Snippet einbindet ist es von diesem abhängig. Daher muss das eingebundene Snippet zuerst bearbeitet werden.

Jedes Snippet unterstützt die Attribute provides und depends. Diese bekommen jeweils eine id oder eine Liste von ids übergeben. Alle Snippets mit einer in depends aufgelisteten id werden bearbeitet bevor das entsprechende Snippet bearbeitet wird. Außerdem werden alle Snippets vorher ausgeführt, die eine in depends aufgeführte id in ihrem provides Attribut aufführen.

Bei der Ausführung überprüft lisi, ob alle benötigten Snippets definiert wurden und ob keine Kreisabhängigkeiten bestehen (z.B. Snippet1 benötigt Snippet2 welches wiederum Snippet1 benötigt). In beiden Fällen würde der AST um eine Fehlermeldung erweitert werden, welche einmal direkt an der jeweiligen Stelle im Asciidoc Code eingefügt wird und einmal in einer Tabelle gleich zu Beginn des Dokumentes mit einem Link auf die Problemstelle.

TODO Implementierung

TODO Soll eine graphische Darstellung des Kontrollflusses generiert werden können? Notfalls wäre das mit pipe leicht implementiert.

TODO Während der Ausführung könnte lisi leicht überprüfen, ob pipe tatsächlich alle ids speichert, die es in provides definiert und ob es keine weiteres definiert.

Benutzerdokumentation erstellen

Viele Kommentare über Literate Programming habe ich so verstanden, dass der Gedanke dabei ist die Programmalgorithmen zu beschreiben und dokumentieren aber nicht die Benutzerdokumentation.

Ich finde diese Trennung macht keinen Sinn und stellt eine unnötige Beschränkung da. Eine Auseinandergehen der Benutzerdokumentation und der Implemntierung ist genauso schlimm, wie Abweichungen der Entwicklerdokumentation von der Implementierung. Das grosse Problem ist wahrscheinlich eher:

  • Man will den Benutzer nicht mit Implementerungsdetails ablenken (die er mitunter gar nicht verstehen kann und die ihn davon abhalten könnten die Informationen zu finden, welche er sucht)

  • Benutzerdokumentation ist schwerer auszuführen und damit auch schwerer auf dem gleichen Stand zu halten, wie die Implementierung.

Diese Probleme versuchte man damit zu umgehen, die Userdoku abzutrennen und jemand separat damit zu beauftragen sie zu pflegen.

Dabei gibt es einen Teil des Quelltextes, welcher geradezu danach schreit, in die Benutzerdokumentation aufenommen zu werden:

Spezifikationen (Unit Tests) schreiben

Unit Tests beschreiben das Verhalten und die Schnittstellen eines Programmes. Damit entsprechen sie genau dem, was den Endnutzer interessiert.

Das erste, was man bei einem Projekt erstellen sollte ist ein gutes Lasten- und Pflichtenheft. Es wird normalerweise in Zusammenarbeit mit dem Kunden oder dem Auftraggeber erarbeitet und legt genau fest, was von einem Programm erwartet wird. Eigentlich ist es nur naheliegend diese Informationen unmittelbar im Quelltext (und zwar in Form von Testcases) zu nutzen.

Bisher ist die gängige Praxis (wenn überhaupt systematisch getestet wird), in den Unittests nochmal seperat die Informationen aus dem Pflichtenheft abzufassen aber diesmal auf die Implementierung zugeschnitten. Das leistet einem Auseinanderdriften von Vorgaben und Implementierung Vorschub (oftmals werden die Tests erst sehr spät in der Entwicklung geschrieben und dann auch oft nur unvollständig).

lisi hebt diese Einschränkung auf. Unit Tests können irgendwo in den Quelltext eingefügt werden. Dass macht es möglich eine normale Benutzerdokumentation zu schreiben und bei jeder Änderung zu überprüfen, ob sich das Nutzererlebnis verändert. Gleichzeitig kann man die Doku flexibel aufteilen z.B. in Getting Started, Tutorials und eine umfangreiche Dokumentation, welche alle Details genau erläutert. Weder der Stil, noch die Aufteilung, noch die Struktur sind fest vorgegeben, sondern können durch die in Transformieren beschrebenen Funktionen dynamisch erstellt werden.

TODO Beispiele mit Quellcode

Probleme mit dem literate programing Ansatz

Es gibt einige Probleme, die man speziell beim literate programing hat, welche bei anderen Herangehensweisen nicht so auftreten. Viele davon hängen allerdings mehr mit den verfügbaren Tools zusammen als mit dieser Programmiermethode an sich.

Bilder, Diagramme und Charts

Um mir einen Überblick über ein Programmkonzept oder eine Architektur zu verschaffen finde ich im Allgemeinen Diagramme am nützlichsten. Oft beginne ich damit diese zu zeichnen.

Im Laufe der Zeit verändern sich jedoch oft die Anforderungen an ein Programm und damit auch die Architektur. So veralten die Diagramme bald.

Ebenso beginnen viele Programme damit, dass sie Daten analysieren (oft als Teil des Programms) und ausgehend von diesen Erkenntnissen das Programm aufbauen. Diese Daten können im Laufe der Zeit veralten.

Lösungsansatz: Wenn man Funktionen hätte um aus Quelltext direkt Diagramme (Flowdiagramme, Zustandsmaschinen, etc) erstellen zu lassen könnte man diese anzeigen und hätte so immer aktuelle Diagramme. Oder man geht umgekehrt vor und generiert aus ASCII-Art Quelltext. Auch dieser bliebe dann immer aktuell.

Um Charts darzustellen kann man Quelltext direkt als Chart ausgeben. Siehe z.B. das Jypiter Projekt (TODO link).

Autovervollständigung und Syntax Highlighting

Der Quelltext ist oft nicht leicht zu highlighten und auch die Verweiszeichen machen es nicht leichter. Zudem ist es sehr schwer eine sinnvolle Autovervollständigung für Quelltexte zu bekommen, da die Snippets verteilt und in der Reihenfolge verschoben sind.

Lösungsansatz: Tools wie treesitter (TODO link) und LSP (TODO link) könnten helfen. Mit dem ersten kann man vielleicht auch sehr kleine Snippets sinnvoll highlighten und mit dem zweiten kann man vieleicht einen Client machen, der den Quelltext virtuell zusammensetzt und auch wieder auseinandernimmt (zurückmappt) dadurch könnte der jeweilige Language-Server unverändert arbeiten und würde gar nicht merken, dass der Quelltext anders zusammengesetzt wird.

Traces zurückverfolgen

Eines der größten Probleme beim Literate Programming scheint mir die Zurückverfolgung von Stack-Traces zu sein.

Sowohl beim Kompilieren als auch beim Debuggen oder dem arbeiten in einer interaktiven Konsole werden immer wieder Dateinamen und Zeilennummern angegeben, welche erkennen helfen sollen welche Stelle im Quelltext für ein Programmverhalten (meistens Fehler) verantwortlich ist. Diese Angaben würden sich natürlich auf den generierten Quelltext beziehen und man kann nicht mehr erkennen, wo sie ursprünglich im asciidoc-Dokument stehen. Würde man an die Stelle im generierten Quellcode navigieren und dort die nötigen Änderungen vornehmen werden das Ursprungsdokument und der tatsächliche Quellcode immer stärker voneinander abweichen und die Dokumentation wird bald nicht mehr korrekt sein. Zudem ist es in diesem Fall schnell nicht mehr möglich das Programm über das eigentliche Quelldokument weiter zu entwickeln, da sich nicht mehr feststellen lässt, ob der frisch erzeugte oder der manuell angepasste Quelltext richtig ist (Merging-Problem). Entscheidet man sich andererseits immer erst die richtige Stelle im Ursprungsdokument zu suchen und dort zu ändern verlangsamt man den Entwicklungsprozess enorm. Ausserdem wird man so viel Energie mit suchen vergeuden, dass nur noch wenig kreative Kraft für die eigentliche Programmentwicklung bleibt.

Daher ist es am besten direkt beim Erzeugen des Quellcodes auch ein Mapping der Zeilen (und eventuell ihrer Transformation) mit anzulegen. Anschließend sollte man die Fehlermeldungen automatisiert korrigieren. Das macht man am besten mit einem Filter, so dass man das (zurück-)mappen nie von Hand anstoßen muss.

Alternative Lösungsansätze und veralteter Code

Je länger ein Programm existiert desto mehr wird es verändert werden und mit alten Codefrakmenten zu kämpfen haben. Es müsste eine Möglichkeit geben Code als "deprecated" oder als "alternative" zu kennzeichnen, damit der Leser weis, dass dieser Code nicht relevant für die Programmausführung ist. Zudem wäre es sehr nützlich gleich zu Beginn des Dokumentes dieses mit einem Status zu versehen (Entwurf, Proof of Konzept, Beta, Stabil, Veraltet, …​) und eventuell direkt auf ein Nachfolgedokument zu verweisen.

Beispiele

Eine Präsentation als literate program

TODO Alles in dieser Sektion sollte später in eine eigene Datei ausgelagert werden. Es ist gleichzeitig ein Beispiel, wie man eine Präsentation als literate program verfassen kann und eine Präsentation von lisi. …​

Präsentationen haben oft ein Problem: Sie sind langweilig, da sie lienear aufgebaut sind, user menschliches Denken aber mit Räumen und Assotiationen arbeitet. Moderne Tools wie prezi (TODO link) sollen da abhelfen und bieten die Möglichkeit Ideen auf eine neue Art dazustellen. Moderne Präsentationen haben ein neues Problem: Der Nutzer ist so auf seine Darstellungsmöglichkeiten fixiert, dass der Inhalt untergeht (das gleiche war früher mit Folienübergängen der Fall).

Um dem abzuhelfen bietet sich literate programing an. Da der Nutzer vor allem versucht seine Ideen als Text zu verfassen stehen sie wieder im Mittelpunkt und die Effekte helfen wieder die Idee klarer herauszustellen, statt als Selbstzweck zu dienen. Im folgenden wird gezeigt, wie man eine moderne Präsentation über den Einsatz von lisi für Präsentationen verfassen kann.

Vorraussetzungen

Wir wollen, dass unsere Präsentation

  • Auf möglichst vielen Geräten lauffähig ist (cross-plattform)

  • Unabhängig von einer Internetverbindung abgespielt werden kann

  • Interaktive elemente enthält

Als Basis benutzen wir daher ein Werkzeug, welches im Browser ausgeführt werden kann (aber nicht zwangsläufig eine Verbindung ins Internet benötigt): impress.js.

Da wir zudem einige interaktive charts einbinden möchten benutzen wir noch d3.

imports
  <script type="text/javascript" src="js/d3.js"></script>
  <script type="text/javascript" src="js/impress.js"></script>

TODO Zeigen, wie man eine Übersicht als svg-Datei einbinden kann und anschließend mit jedem Schritt einen Ausschnitt davon anzeigen und beschreiben kann…​

Einen Issue-Tracker aus TODO.adoc und Changes.adoc Dokumenten erstellen

TODO Alles in dieser Sektion sollte später in eine eigene Datei ausgelagert werden. Es zeigt, wie man asciidoctor und lisi dazu nutzen kann ein verteiltes Issue-Tracker Programm (samt Webinterface) zu erstellen.

Ähnlich wie Programme Dokumentation sind, so sind auch die Tickets in Issue-Trackern Dokumentation. Sie beschreiben die Fortentwicklung eines Programms (wichtig unter anderem für support und Kompatibilitäts-Checks), sowie die Ziele für die Zukunft. In den vorhandenen Programmlösungen werden diese Informationen vom eigentlichen Programm getrennt. Da man sie oft dennoch benötigt muss (redundant) eine Changes-Datei gepflegt werden um Nutzer über Neuerungen und deren Anwendung zu informieren. Dies bedeutet zusätzlichen Pflegeaufwand und eine potentielle Fehlerquelle.

Zudem werden immer mehr Programme verteilt entwickelt (was viele Vorteile mit sich bringt TODO link zu git Buch), aber die bisherigen Issue-Programme sind alle zentralisiert und lassen kein verteiltes abarbeiten von Tickets zu.

Ausserdem können diese Ticket-Verwaltungen ausschließlich über ein webinterface bedient werden. Für Entwickler wäre es wünschenswert einfach Textdateien bearbeiten zu können…​

Implementierung

Codeschnipsel verarbeiten

Eine Datenbank für Codeschnipsel anlegen

Um die Snippets zu verarbeiten müssen wir leicht auf sie zugreifen können. Das Ziel der Extrakt Phase ist es alle Schnipsel in eine Datenbank (oder Cache je nach Sichtweise) zu überführen, wo wir wahlfrei darauf zugreifen können. Dafür verwenden wir eine HashMap.

use std::collections::HashMap;
use std::collections::hash_map;
pub struct SnippetDB {
  snippets: HashMap<String, Snippet>,
}

impl SnippetDB {
  pub fn new() -> Self {
    SnippetDB {
      snippets: HashMap::default(),
    }
  }

  <<snippet_db_functions>>

  /// Get the snippet with the name `name`
  pub fn get(&self, name: &str) -> Option<&Snippet> {
    self.snippets.get(name)
  }

  /// Get the snippet with the name `name` and
  /// remove it from the snippet database
  pub fn pop(&mut self, name: &str) -> Option<Snippet> {
    self.snippets.remove(name)
  }

  /// Get iterator over all snippets
  pub fn iter(&self) -> hash_map::Iter<String, Snippet> {
    self.snippets.iter()
  }
}

Jeder Snippet kann einer von vier Kategorien zugewiesen werden.

#[derive(Clone, Debug)]
pub enum SnippetType {
  Save(String), (1)
  Eval(String), (2)
  Pipe,         (3)
  Plain,        (4)
}
1 Er kann in eine Datei abgespeichert werden (TODO link)
2 Oder von einem Interpreter ausgeführt werden (TODO link)
3 Oder zur Erzeugung von dynamischen Snippet benutzt werden (TODO link)
4 Oder keine besondere Funktion haben. Dann wird er meist von anderen Snippets eingebunden (TODO link).

Zusätzlich hat ein Snippet noch einige weitere Eigenschaften, welche die Verarbeitung ermöglichen.

#[derive(Clone, Debug)]
pub struct Snippet {
  pub kind: SnippetType,
  pub content: String,         (2)
  pub raw_content: String,
  pub children: Vec<Snippet>,  (1)
  /// List of all keys the snippet depends on
  /// before it can be processed
  pub depends_on: Vec<String>, (3)
  pub attributes: HashMap<String, String>,
  pub raw: bool,
}

impl Snippet {
  <<snippet_functions>>
}
1 Ein Snippet kann aus mehreren aneinandergehängten Snippets bestehen (TODO link).
2 Dadurch muss der Text des Snippets aus allen anderen Snippets berechnet werden.
3 Snippets haben andere Snippets, die sie einbinden, oder man möchte eine explizite Reihenfolge festlegen (TODO link). Daher werden hier alle Snippets aufgelistet, die vorher verarbeitet werden müssen.

Den AST filtern und die Datenbank füllen

/// Gets recursively all snippets from an element
pub fn extract(&mut self, mut snippets: SnippetDB, input: &ElementSpan) -> Result<SnippetDB, Error> {
  match &input.element {
    Element::TypedBlock {
      kind: BlockType::Listing,
    } => { (1)
      <<check_is_lisi_code_block>>
      <<extract_attributes|join="\n\n">>
      <<find_references>>
      <<store_snippet_in_internal_db>>

      Ok(snippets)
    }
    Element::Styled => { (1)
      <<check_is_inline_code_block>>
      <<inline_extract_attributes>>
      <<store_snippet_in_internal_db>>

      Ok(snippets)
    }
    Element::IncludeElement(ast) => ast (2)
      .inner
      .elements
      .iter()
      .try_fold(snippets, |snippets, element| {
        self.extract(snippets, element)
      }),
    _ => input.children.iter().try_fold(snippets, |snippets, element| { (2)
      self.extract(snippets, element)
    }),
  }
}
1 Ist ein Element ein Code-Snippet (ob Block oder Inline) wird es weiterverarbeitet.
2 Falls ein Element zwar kein Snippet ist aber Unterknoten hat, wird rekursiv weiter nach Quellcode-Snippets gesucht.
Nur Codeschipsel verarbeiten, die auch von Lisi verwendet werden

Es gibt die verschiedensten Codeschnipsel. Nicht alle werden auch verwendet um Programme zu generieren. In Asciidoc haben Blocks mit Quellcode als ersten Parameter source. lisi verarbeitet nur diese Blocks.

let args = &mut input.positional_attributes.iter();
if !(args.next() == Some(&AttributeValue::Ref("source"))) {
  return Ok(snippets);
}

Wenn es sich dagegen um ein Inline Snippet handelt erkennt man es daran, dass es einen Anker hat.

let id = match input.get_attribute("anchor") {
  Some(id) => id.to_string(),
  None => { return Ok(snippets); },
};

Das zweite Attribut gibt den Interpreter an. Falls dieser nicht durch eine spezielle Anpassung überschrieben wird.

let mut interpreter = None;
if let Some(value) = args.next()  {
  match &value {
    AttributeValue::Ref(value) => {
      interpreter = Some(*value);
    },
    AttributeValue::String(value) => {
      interpreter = Some(value.as_str());
    }
  }
}
Dem Snippet alle wichtigen Attribute übergeben

Es gibt einige Attribute der Codeschnipsel im AST, die für die Weiterverarbeitung durch lisi wichtig sind.

Das Pfad Attribut ist wichtig für alle save Snippets (TODO link). Falls es nicht explizit definiert wurde, gehen wir davon aus, das der Titel des Codeblocks den Pfad enthällt.

let title = input.get_attribute("title");
let path = input.get_attribute("path").or(title);

Die id benötigen wir, damit Snippets aufeinander verweisen können. Falls sie im Quelldokument nicht definiert wurde verwenden wir die Anfangs- und Endposition des Blocks um eine eindeutige id zu bekommen.

let id = input.get_attribute("anchor").unwrap_or(
  &format!("_id_{}_{}", input.start, input.end),
).to_string(); // TODO Vielleicht Datei + Zeile?

Außerdem gehen wir alle Attribute durch und überschreiben unsere Standardwerte falls das Attribut definiert wurde.

let interpreter = input.get_attribute("interpreter").or(interpreter);
let mut raw = false;

Ebenso benötigen wir einen Snippet Typ (TODO link). Er wird in den positionsabhängigen Argumenten definiert. Falls nicht vorgegeben wurde, gehen wir davon aus, das es ein Snippet ohne besondere Verarbeitung ist.

let mut kind = SnippetType::Plain;

for argument in args {
  match argument {
    AttributeValue::Ref("save") => {
      let path = path.ok_or(Error::Missing)?;
      kind = SnippetType::Save(path.to_string());
    }
    AttributeValue::Ref("eval") => {
      let interpreter = interpreter.clone().ok_or(Error::Missing)?;
      kind = SnippetType::Eval(interpreter.to_string());
    }
    AttributeValue::Ref("pipe") => {
      kind = SnippetType::Pipe;
    }
    AttributeValue::Ref("lisi-raw") => {
      raw = true;
    }
    _ => (),
  }
}
#[error("a nessessary attribute is missing")]
Missing,

Bei inline Snippets ist das etwas einfacher, da es hier nur normale Schnipsel gibt.

let kind = SnippetType::Plain;
let raw = false;
let dependencies = Vec::new();

Alle weiteren Attribute werden in einer HashMap abgelegt, die später von der Pipe (TODO link verarbeitet werden kann).

let mut attributes: HashMap<String, String> = HashMap::default();

for key in input.attributes.iter().map(|attr|{ attr.key.clone() }) {
  attributes.insert(key.clone(), input.get_attribute(&key).unwrap().to_string());
}
let mut attributes: HashMap<String, String> = HashMap::default();

for key in input.attributes.iter().map(|attr|{ attr.key.clone() }) {
  attributes.insert(key.clone(), input.get_attribute(&key).unwrap().to_string());
}
Snippets in der Datenbank speichern

Ist ein Snippet aus dem AST herausgefiltert worden, können wir es in der Datenbank abspeichern.

snippets.store(
  id.to_string(),
  Snippet {
    kind,
    content: content.to_string(),
    raw_content: content.to_string(),
    children: Vec::new(),
    depends_on: dependencies,
    attributes,
    raw,
  },
);

Wir rufen dazu die interne Funktion store auf.

/// Stores a snippet in the internal database
pub fn store(&mut self, name: String, snippet: Snippet) {
  let base = self.snippets.get_mut(&name); (1)
  match base {
    Some(base) => { (2)
      if &base.children.len() < &1 {
        let other = base.clone();
        &base.children.push(other);
      }
      <<copy_dependencies_to_base_snippet>>
      base.children.push(snippet);
    }
    None => { (3)
      self.snippets.insert(name, snippet);
    }
  }
}
1 Zunächst wird geprüft, ob bereits ein Snippet mit dieser Id gespeichert wurde.
2 Falls ja wird es an das Bestehende angehängt.
3 Falls nicht kann man es einfach abspeichern.

Der Schnipsel ist natürlich abhängig von allen Referenzen der Sub-Schnipsel. Deshalb müssen diese Abhängigkeiten in den Hauptschnipsel übertagen werden.

for dependency in snippet.depends_on.clone().into_iter() {
  base.depends_on.push(dependency);
}

Referenzen in einem Snippet finden

Wir möchten, die referenzierten Snippets später einbinden. Dazu müssen sie verarbeitet werden können, bevor das Snippet, welches sie verwendet, verarbeitet wird. Aus diesem Grund parsen wir den (unverarbeiteten) Inhalt des Snippets.

Beim verwenden, müssen wir zunächst einmal sichergehen, dass das Snippet überhaupt einen Inhalt definiert hat. Falls nicht gehen wir davon aus, dass es leer ist.

let content = input
  .get_attribute("content")
  .unwrap_or(input.content);
let content = input
  .get_attribute("content")
  .unwrap_or(input.content);

Um die Referenzen zu finden verwenden wir die Pest Bibliothek. Sie basiert auf Parsing Expression Grammars und wird bereits von asciidoctrine verwendet. Diese Art von Parsern ist (für mich) sehr leicht zu lesen und zu schreiben.

#[macro_use]
extern crate pest_derive;
pest = "2.1.0"
pest_derive = "2.1.0"

Wir lagern sie in ein eigenes Modul aus.

mod codeblock_parser;
src/codeblock_parser.rs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use pest::Parser;

use crate::*;

#[derive(Parser, Debug)]
#[grammar = "codeblock.pest"]
pub struct CodeblockParser;

<<codeblock_parser_internal_structs>>

<<codeblock_parser_functions|join="\n\n">>

/// Extracts the ids of used snippets from a depending snippet
pub fn get_dependencies(input: &str) -> Vec<String> {
  <<get_dependencies>>
}

/// Merges the snippets into the depending snippet
pub fn merge_dependencies(input: &str, snippets: &SnippetDB, key: &str) -> String {
  <<merge_dependencies>>
}

Sie hat zwei wichtige Funktionen:

get_dependencies

Parsed einen Snippet und gibt alle intern definierten Referenzen zurück.

merge_dependencies

Fügt an den Stellen der Referenzen die tatsächlichen Inhalte ein. Wir verwenden sie später im Abschnitt Ausgaben erzeugen (TODO link).

Zu Beginn bindet das Modul die Parserdatei ein. Ein Codeblock besteht aus ein paar wesentlichen Elementen.

Code

Dieser wird später vom Compilier oder Interpreter verarbeitet und lisi muss ihn nicht verändern.

Referenzen

Enthalten Verweise auf andere Snippets.

Eingerückte Referenzen

Ist eine Referenz eingerückt, so wollen wir, dass jede Zeile des eingefügten Snippets ebenfalls eingerückt wird. Ansonsten wäre der generierte Code nicht schön formattiert.

Kommentaren

Diese Kommentare sind nur für die Anzeige in Asciidoc gedacht und sollen später nicht im generierten Quelltext vorhanden sein.

src/codeblock.pest
1
2
3
4
5
6
7
8
codeblock = _{ (code | indented_reference | reference | comment)* ~ EOI }

reference = { <<reference>> }
indented_reference = { <<indented_reference>> }
code = { <<code_gramma>> }
comment = { <<comment>> }

<<internal_gramma_elements>>

Eine Referenz wird durch eine von doppelten spitzen Klammern umrahmten id dargestellt. Es ist möglich auch noch Attribute mit zu übergeben um die Art, wie der Schnipsel eingebunden wird zu modifizieren.

reference
"<<" ~ identifier ~ (empty ~ "|" ~ empty ~ attributes)? ~ ">>"

Wobei eine id nur aus ASCII Buchstaben, Unterstrich und Verbindungsstrich bestehen darf. Zudem darf sie nicht mit einem Verbindungsstrich beginnen, um nicht den wie eine Minus Expression zu wirken (und damit Verwirrung zu stiften).

identifier
identifier = @{ (ASCII_ALPHANUMERIC | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-" )* }

Attribute bestehen aus einem Namen und dem dazugehörigen Inhalt (oder Wert) die durch ein = Zeichen verbunden werden. Der Name ist eine Id und der Inhalt wird in Hochkommata (") eingerahmt. Es können beliebig viele Attribute übergeben werden. Diese müssen durch Kommas getrennt werden. Es können aber auch Snippet Parameter als Inhalt übergeben werden. In diesem Fall wird := als Zuweisungszeichen verwendet. Als Parameter können entweder normale Inhalte in Hochkommata übergeben werden oder (beliebig tief geschachtelte) Referenzen.

attributes = { (attribute | attribute_param) ~ empty
  ~ ("," ~ empty ~ (attribute | attribute_param) ~ empty)* }
attribute = { identifier ~ "=" ~ "\"" ~ value ~ "\"" }
attribute_param = { identifier ~ empty ~ ":=" ~ empty
  ~ (("\"" ~ value ~ "\"") | reference) }
value = @{ ( !"\"" ~ ANY | "\\\"")* }
#[derive(Debug, Clone)]
enum ReferenceParam {
  Value(String),
  Reference(String, String),
}

type SnippetParams = Vec<HashMap<String, ReferenceParam>>;

Bei einer eingerückten Referenz definieren wir die Einrückung seperat um sie später (TODO link) wiederverwenden zu können.

Eingerückte Referenz
(SOI | NEWLINE) ~ indentation ~ reference
whitespace = @{ (" " | "\t") }
indentation = @{ whitespace+ }
empty = @{ (" " | "\t" | "\n" | "\r")* }

Als Quellcode betrachten wir alles, was keine Referenz und kein Kommentar ist.

(!indented_reference ~ !reference ~ !comment ~ ANY)+

Ein Kommentar ist ein typischer Kommentarbeginn zusammen mit einem Callout (TODO link auf asciidoctor oder asciidoctrine Dokumentation).

optspaces ~ ("//" | "#" | ";;" ) ~ optspaces ~ "<" ~ ASCII_DIGIT+ ~ ">" ~ optspaces ~ &(EOI | NEWLINE)

Dabei dürfen whitespaces zwischen den Elementen vorkommen

optspaces = @{ whitespace* }

Snippets zusammenfügen

Bevor die Snippets verwendet werden, müssen alle Referenzen durch die tatsächlichen Inhalte ersetzt werden. Dazu benutzen wir die Funktion merge_dependencies (TODO link).

if snippet.children.len() > 0 {
  let mut children = Vec::new();
  for mut child in snippet.children.into_iter() {
    let content = child.content;
    let content = codeblock_parser::merge_dependencies(content.as_str(), &snippets, key);
    child.content = content;
    children.push(child);
  }
  snippet.children = children;
} else {
  let content = snippet.content;
  let content = codeblock_parser::merge_dependencies(content.as_str(), &snippets, key);
  snippet.content = content;
}

In dieser Funktion wird ein String erzeugt, die Referenzen im Snippet durch den tatsächlichen Inhalt ersetzt.

let ast = CodeblockParser::parse(Rule::codeblock, input).expect("couldn't parse input.");
let snippet_params = extract_snippet_params(Vec::from([HashMap::default()]), input);

merge_dependencies_inner(ast, snippets, snippet_params, key)
fn merge_dependencies_inner<'a>(
  ast: pest::iterators::Pairs<'a, codeblock_parser::Rule>,
  snippets: &SnippetDB,
  snippet_params: SnippetParams,
  key: &str,
) -> String {
  let mut output = String::new();

  for element in ast {
    match element.as_rule() {
      Rule::reference => {
        <<get_modified_snippet>>
      }
      Rule::indented_reference => {
        let mut indented_output = String::new();
        let indentation = extract_indentation(&element);
        <<get_modified_snippet|
            output_variable := <<indented_output>>>>
        indent(&indented_output, indentation, &mut output); (2)
      }
      Rule::code => {
        output.push_str(element.as_str());
      }
      _ => (),
    }
  }
  output
}
1 Bei eingerückten Referenzen muss zusätzlich jede Zeile des Inhalts eingerückt werden. Deswegen wird statt output zunächst indented_output verwendet.
let key = element.as_str().trim_start();
let identifier = extract_identifier(&element);
let join_str = extract_join_str(&element).replace("\\n", "\n");

substitude_params(
  identifier,
  snippets,
  snippet_params.clone(),
  &join_str,
  key,
  &mut <<output_variable>>,
);

Das einsetzen der Snippets läuft immer etwa gleich ab: Entweder es gibt einen Parameter welcher ersetzt werden muss oder man ersetzt die Referenzen durch Snippets aus der Datenbank.

fn substitude_params(
  identifier: &str,
  snippets: &SnippetDB,
  snippet_params_history: SnippetParams,
  join_str: &str,
  key: &str,
  output: &mut String,
) {
  let mut snippet_params_history = snippet_params_history;
  let snippet_params = snippet_params_history.pop().unwrap_or_default();

  match snippet_params.get(identifier) {
    Some(param) => match param {
      ReferenceParam::Value(param) => output.push_str(&param),
      ReferenceParam::Reference(param, subparams) => {
        <<use_snippet_parameter>>
      }
    }
    None => match snippets.get(identifier) {
      <<use_snippet_from_database>>
    }
  }
}

Wenn es einen Parameter gibt und der Parameter eine Referenz ist, versuchen wir diese Referenz herauszusuchen. Das ist im Grunde die gleiche Prozedur die wir bis jetzt auch schon angewendet haben. Die Funktion kann also sich selbst aufrufen.

Dabei müssen wir einiges beachten:

  • Zunächst müssen wir sicher gehen, dass der Name des Parameters nicht der gleiche ist, wie die Referenz. Ansonsten würde sich die Funktion endlos selbst aufrufen.

    • Falls der Parameter identisch ist, durchsuchen wir die Aufrufhierarchie um zu überprüfen, ob einer der aufrufenden Snippets den Parameter definert hat.

  • Außerdem haben Referenzen manchmal eigene Parameter. Diese müssen wir ebenfalls auslesen.

if param != identifier {
  snippet_params_history.push(snippet_params.clone());
}
substitude_params(
  &param,
  snippets,
  snippet_params_history,
  join_str,
  &subparams, (1)
  output,
);
1 Beim extrahieren der Parameter verwenden wir im Fall einer Referenz die beim Aufruf festgelegten Parameter.

Wenn der Snippet aus der Datenbank übernommen wird, gibt es zwei Möglichkeiten:

  • Wenn er vorhanden ist können wir ihn einfach übernehmen (und natürlich die Parameter ersetzen)

  • Wenn er nicht vorhanden ist, prüfen wir, ob Parameter in einer Referenz übergeben wurden. Falls ja, übernehmen wir diese. Falls nicht, geben wir eine Warnung aus und lassen den Snippet leer.

Some(snippet) => {
  if let SnippetType::Pipe = snippet.kind {
    warn!("depends on pipe snippet {}", identifier);
  }

  let input = snippet.get_raw_content(&join_str);

  let content = if snippet.raw {
    input
  } else {
    let ast = CodeblockParser::parse(Rule::codeblock, &input).expect("couldn't parse input.");

    snippet_params_history.push(snippet_params);
    let snippet_params = extract_snippet_params(snippet_params_history, key);

    merge_dependencies_inner(ast, snippets, snippet_params, key)
  };
  output.push_str(&content);
}
None => {
  snippet_params_history.pop(); (1)
  if let Some(params) = snippet_params_history.pop() {
    if params.get(identifier).is_some() {
      snippet_params_history.push(params);
      substitude_params(
        identifier,
        snippets,
        snippet_params_history,
        join_str,
        key,
        output,
      );
    } else {
      warn!(
        "Couldn't find snippet dependency `{}` for `{}`",
        identifier, key
      );
    }
  } else {
    warn!(
      "Couldn't find snippet dependency `{}` for `{}`",
      identifier, key
    );
  }
}
1 Parameter wurden oft in der vorigen Ebene übergeben. Deshalb gehen wir in der Historie einen Schritt zurück um zu schauen, ob welche übergeben wurden.
extract_identifier und extract_indentation
fn extract_identifier<'a>(element: &pest::iterators::Pair<'a, codeblock_parser::Rule>) -> &'a str {
  match element.as_rule() {
    Rule::reference => element.clone().into_inner().next().unwrap().as_str(),
    Rule::indented_reference => {
      let mut output = "";
      for element in element.clone().into_inner() {
        match element.as_rule() {
          Rule::reference => {
            output = element.into_inner().next().unwrap().as_str();
            break;
          }
          _ => (),
        }
      }
      output
    }
    _ => "",
  }
}

fn extract_join_str<'a>(element: &pest::iterators::Pair<'a, codeblock_parser::Rule>) -> &'a str {
  match element.as_rule() {
    Rule::reference => {
      match element
        .clone()
        .into_inner()
        .find(|element| match element.as_rule() {
          Rule::attributes => true,
          _ => false,
        }) {
        Some(element) => extract_join_str(&element),
        None => "\n",
      }
    }
    Rule::attributes => {
      match element
        .clone()
        .into_inner()
        .find(|element| match element.as_rule() {
          Rule::attribute => {
            let mut attribute = element.clone().into_inner();
            let key = attribute.next().unwrap();

            key.as_str() == "join"
          }
          _ => false,
        }) {
        Some(element) => {
          let mut attribute = element.clone().into_inner();
          attribute.next();
          let value = attribute.next().unwrap();

          value.as_str()
        }
        None => "\n",
      }
    }
    Rule::indented_reference => {
      match element
        .clone()
        .into_inner()
        .find(|element| match element.as_rule() {
          Rule::reference => true,
          _ => false,
        }) {
        Some(element) => extract_join_str(&element),
        None => "\n",
      }
    }
    _ => "\n",
  }
}

fn extract_indentation<'a>(element: &pest::iterators::Pair<'a, codeblock_parser::Rule>) -> &'a str {
  let mut output = "";
  for element in element.clone().into_inner() {
    match element.as_rule() {
      Rule::indentation => {
        output = element.as_str();
        break;
      }
      _ => (),
    }
  }
  output
}

fn indent(content: &str, indentation: &str, output: &mut String) -> () {
  for line in content.lines() {
    output.push_str("\n");
    output.push_str(indentation);
    output.push_str(line);
  }
}

Wir müssen auch mögliche Parameter, welche beim Aufruf des Snippets eventuell übergeben wurden, herausfiltern und abspeichern.

fn extract_snippet_params(snippet_params_history: SnippetParams, param: &str) -> SnippetParams {
  let mut snippet_params = snippet_params_history.clone().pop().unwrap_or_default();
  let mut new_params = HashMap::default();
  let ast = CodeblockParser::parse(Rule::codeblock, &param).expect("couldn't parse input.");
  let mut snippet_params_history = snippet_params_history;

  let ref_iter = ast.clone().filter(|element| match element.as_rule() {
    Rule::reference => true,
    _ => false,
  });
  let indent_ref_iter = ast
    .clone()
    .filter(|element| match element.as_rule() {
      Rule::indented_reference => true,
      _ => false,
    })
    .flat_map(|element| element.clone().into_inner())
    .filter(|element| match element.as_rule() {
      Rule::reference => true,
      _ => false,
    });

  for element in ref_iter.chain(indent_ref_iter) {
    for element in element
      .clone()
      .into_inner()
      .filter(|element| match element.as_rule() {
        Rule::attributes => true,
        _ => false,
      })
      .flat_map(|element| element.clone().into_inner())
      .filter(|element| match element.as_rule() {
        Rule::attribute_param => true,
        _ => false,
      })
    {
      let identifier = element
        .clone()
        .into_inner()
        .find(|element| match element.as_rule() {
          Rule::identifier => true,
          _ => false,
        })
        .map(|element| element.as_str().to_string())
        .unwrap();
      let value = element
        .into_inner()
        .find(|element| match element.as_rule() {
          Rule::value => true,
          Rule::reference => true,
          _ => false,
        })
        .map(|element| match element.as_rule() {
          Rule::value => Some(ReferenceParam::Value(element.as_str().to_string())),
          Rule::reference => match snippet_params.remove(&identifier) {
            Some(param) => Some(param),
            None => {
              let inner_params = element.as_str().to_string();
              let identifier = extract_identifier(&element);

              match snippet_params.remove(identifier) {
                Some(param) => Some(param),
                None => Some(ReferenceParam::Reference(
                  identifier.to_string(),
                  inner_params,
                )),
              }
            }
          },
          _ => None,
        })
        .unwrap();

      if let Some(value) = value {
        new_params.insert(identifier, value);
      }
    }
  }

  snippet_params_history.push(new_params);
  snippet_params_history
}

Hierfür müssen wir den Inhalt eines Snippets genereieren können.

fn get_raw_content(&self, join_str: &str) -> String {
  if self.children.len() > 0 {
    let mut iter = self.children.iter();
    let start = iter.next().unwrap().raw_content.clone();
    iter.fold(start, |mut base, snippet| {
      base.push_str(join_str);
      base.push_str(&snippet.raw_content);
      base
    })
  } else {
    self.raw_content.to_string()
  }
}

Die Verarbeitungsreihenfolge der Snippets festlegen

Eines der wichtigsten Features von lisi (und das, welches, wie ich glaube, es am stärksten von vergleichbaren Tools unterscheidet), ist, dass man den Kontrollfluss bestimmen kann. Dadurch wird es in gewissem Sinne zu einer Dataflow Sprache.

Damit das möglich wird muss herausgefunden werden, welches Snippet verarbeitet werden kann, und welches von anderen abhängt, die vorher verarbeitet werden müssen. Dazu verwenden wir die Topoligical Sorting Methode. Wir implementieren sie nicht selbst, sondern benutzen den topological-sort (TODO link) crate.

topological-sort = "0.2"
use topological_sort::TopologicalSort;

Die entsprechende Klasse (Trait, wieauchimmer) nehmen wir in die internen Variablen auf, denn es ergänzt unsere Snippet Datenbank (TODO link).

dependencies: TopologicalSort<String>,

Und initialisieren sie bei der Initialisierung der Lisi Struktur.

dependencies: TopologicalSort::new(),

Nachdem wir die Snippets in der Datenbank abgelegt haben gehen wir durch und füllen unsere Sortierstruktur.

/// Builds the dependency tree for topological sorting
pub fn calculate_snippet_ordering(&mut self, snippets: &SnippetDB) {
  for (key, snippet) in snippets.iter() {
    // TODO Vielleicht sollten nur `save` und `eval` snippets
    // unabhängig von dependencies aufgenommen werden?
    self.dependencies.insert(key); (1)

    for child in snippet.children.iter() { (2)
      for dependency in child.depends_on.iter() {
        self.dependencies.add_dependency(dependency, key);
      }
    }
    for dependency in snippet.depends_on.iter() { (2)
      self.dependencies.add_dependency(dependency, key);
    }
  }
}
1 Jedes Snippet muss in die Sortierung mit eingebunden werden, auch, wenn es keine Abhängigkeiten hat. Sonst könnten direkt ausgeführte Snippets ohne Abhängigkeiten verloren gehen.
2 Zudem müssen alle Abhängigkeiten bekanntgegeben werden.

Wir verwenden die calculate_snippet_ordering Funktion um die abhängigen keys zu einem Snippet zu finden und zu speichern.

let mut dependencies = Vec::new();
for dependency in codeblock_parser::get_dependencies(content).iter() {
  dependencies.push(dependency.to_string());
}

Intern ist sie folgendermaßen aufgebaut:

let mut depends_on_ids = Vec::new();

let ast = CodeblockParser::parse(Rule::codeblock, input).expect("couldn't parse input.");

for element in ast {
  match element.as_rule() {
    Rule::reference => {
      depends_on_ids.push(extract_identifier(&element).to_string());
      <<get_dependencies_of_parameters>>
    }
    Rule::indented_reference => {
      depends_on_ids.push(extract_identifier(&element).to_string());
      <<get_dependencies_of_parameters>>
    }
    _ => (),
  }
}

depends_on_ids

Snippets sind nicht nur von den Referenzen abhängig, die direkt vorkommen, sondern auch von den Referenzen in den Snippet Parametern.

let params = extract_snippet_params(Vec::default(), element.as_str())
  .pop()
  .unwrap_or_default();
for param in params.into_values() {
  if let ReferenceParam::Reference(identifier, _) = param {
    depends_on_ids.push(identifier.clone());
  }
}

Ausgaben erzeugen

Um Ausgaben erzeugen zu können holen wir die Code-Schnipsel in der topologisch sortierten Reihenfolge ab und verarbeiten sie anschließend gemäß ihrem jeweiligen Typ.

let source = ast.get_attribute("source").unwrap_or("");
let db = Rc::new(RefCell::new(snippets));
let snippets = Rc::clone(&db);

loop {
  let key = self.dependencies.pop(); (1)
  let snippet = match &key {
    Some(key) => {
      let mut snippets = snippets.borrow_mut();
      let snippet = snippets.pop(&key);

      match snippet {
        Some(mut snippet) => {
          if !snippet.raw {
            <<merge_snippet_content>>
          };

          snippets.store(key.to_string(), snippet.clone());
          Some(snippet)
        }
        None => { (4)
          // TODO Fehlermeldung im AST. Ein Snippet sollte zu
          // diesem Zeitpunkt immer bereits erstellt sein.
          warn!("{}: Dependency `{}` nicht gefunden", source, key);
          None
        }
      }
    }
    None => { (2)
      if !self.dependencies.is_empty() { (3)
        error!(
          "Es ist ein Ring in den Abhängigkeiten ({:#?})",
          self.dependencies
        );
      }
      break; (2)
    }
  };

  if let Some(snippet) = snippet {
    <<execute_snippet_action>>
  }
}
1 Die Snippets müssen in der richtigen Reihenfolge abgearbeitet werden. Ansonsten könnte es passieren, dass ein Snippet verwendet werden soll bevor er überhaupt generiert wurde. (TODO link vielleicht in das andere Kapitel verschieben?)
2 Wird kein weiteres Snippet gefunden, so kann das zwei Gründe haben: Entweder gibt es einen Ring in den Abhängigkeiten oder alle Snippets wurden bereits verarbeitet. In beiden Fällen wird die Programmausführung beendet.
3 Ringe in den Abhängigkeiten sind problematisch, da Snippets, die von sich selbst abhängen, nicht generiert werden können. Daher muss der Benutzer über seinen Fehler unterrichtet werden.
4 Wird ein Snippet gefunden, aber es ist keines unter diesem Namen in der Datenbank abgelegt, muss eine Fehlermeldung generiert werden. Wahrscheinlich wurde dann ein Snippet referenziert aber nie definiert.

TODO Wir sollten auch warnen, wenn ein Snippet zwar einen anchor hat aber niergendwo eingebunden wird. Das würde auf einen Fehler in der Logik deuten, da man wahrscheinlich vergessen hat ihn einzubinden. Diese Überprüfung sollte erst nach der Abarbeitung der Pipes abgeschlossen werden, da manche Snippets eventuell dynamisch eingebunden werden und dementsprechend doch nicht vergessen wurden.

Je nach Snippet Typ können wir nun die entsprechende Aktion ausführen.

match &snippet.kind {
  SnippetType::Eval(interpreter) => {
    self.eval(interpreter.to_string(), snippet.content)?;
  }
  SnippetType::Plain => {}
  SnippetType::Save(path) => {
    <<get_filepath>>
    self.save(path, &snippet.content)?;
  }
  SnippetType::Pipe => {
    self.pipe(&snippet.content, &db)?;
  }
}

Save: Snippet in eine Datei speichern

Um eine Datei zu speichern haben wir eine eigene Funktion.

/// Saves a Snippet to a file
pub fn save(&mut self, path: &str, content: &str) -> Result<(), Error> {
  <<strip_all_lines_in_content>>

  // TODO Allow directory prefix from options
  <<check_path_not_allready_used_by_lisi>>

  self.env.write(path, &content)?;

  Ok(())
}

Um Dateien schreiben zu können müssen wir auf die Betriebsystem-Umgebung zugreifen.

Fehler, die dabei auftreten können, müssen wir abfangen.

#[error(transparent)]
Asciidoctrine(#[from] asciidoctrine::AsciidoctrineError),
#[error("io problem")]
Io(#[from] std::io::Error),

In einer Datei kann es sehr nervig sein, Whithespaces an den Zeilenenden zu haben. Dies kann aber geschehen wenn in der Quelldatei Whitespaces am Ende der Zeilen sind. Selbst wenn das nicht der Fall ist geschieht es durch unsere Einrückungen mitunter automatisch (TODO link). Wir lösen das Problem, indem wir unmittelbar vor dem schreiben in eine Datei "aufräumen".

let content = content.lines()
                     .map(|line| { String::from(line.trim_end()) + "\n" })
                     .collect::<String>();

Eval: Ein Snippet ausführen

/// Run a snippet in an interpreter
pub fn eval(&mut self, interpreter: String, content: String) -> Result<(), Error> {
  <<get_eval_interpreter>>

  let (success, out, err) = self.env.eval(&interpreter, &content)?;

  <<process_stdout_and_stderr>>

  Ok(())
}

Nachdem der Prozess ausgeführt wurde können wir seine Ausgaben (über stdout und stderr) in das Ausgabedokument (den AST) übernehmen. Dabei ist es nicht nur interessant Texte anzuzeigen sondern es ist auch möglich beliebige Inhalte anzuzeigen.

// TODO in den Asciidoc AST einbinden
if success {
  info!("{}", out); // TODO entfernen
} else {
  error!("External command failed:\n {}", err) // TODO entfernen
}

TODO Die Ergebnisse ließen sich sicher auch als Bilder, Audio etc einbinden. Hier kann man bestimmt etwas von https://jupyter.org/ und https://observablehq.com/ lernen.

Um die Daten anzuzeigen welche dargestellt werden sollen wird der stdout Stream nach dem Delimiter (TODO link) abgesucht, welcher den speziellen Inhalt enthält. Dann wird der mime_type (TODO Wikipedia link) ermittelt. Alle binären Daten müssen in base64 (TODO link) Format umgewandelt werden.

TODO

Pipe: Snippets dynamisch erzeugen

Beim pipe Befehl werden snippets als interne Scripte ausgeführt. Wir verwenden rhai als Interpreter.

rhai = "1.3"
/// Use a snippet to manipulate the db instead of using it directly
pub fn pipe(&mut self, content: &str, db: &Rc<RefCell<SnippetDB>>) -> Result<(), Error> {
  <<do_pipe>>

  Ok(())
}

Jede pipe bekommt ihre eigene Script Umgebung.

let mut engine = rhai::Engine::new();

let mut scope = rhai::Scope::new();

let wrapper = LisiWrapper {
  snippets: Rc::clone(&db)
};
scope.push_constant("lisi", wrapper);

engine.register_type_with_name::<LisiWrapper>("LisiType");
engine.register_fn("store", LisiWrapper::store);
engine.register_fn("get_snippet", LisiWrapper::get_snippet);
engine.register_fn("get_snippet_names", LisiWrapper::get_snippet_names);

engine.eval_with_scope::<()>(&mut scope, content)
  .unwrap_or_else(|e| {
    error!("Piping of snippet failed:\n {}", e);
  });

Wir übergeben dem Interpreter eine Funktionsumgebung, welche die grundlegenden Funktionen zulässt.

#[derive(Clone)]
struct LisiWrapper {
  pub snippets: Rc<RefCell<SnippetDB>>,
}

impl LisiWrapper {
  pub fn store(&mut self, name: &str, content: &str) {
    let mut snippets = self.snippets.borrow_mut();

    snippets.pop(name); (1)

    snippets.store(
      name.to_string(),
      Snippet {
        kind: SnippetType::Plain,
        content: content.to_string(),
        raw_content: content.to_string(),
        children: Vec::new(),
        depends_on: Vec::new(),
        attributes: HashMap::default(),
        raw: true,
      },
    );
  }

  pub fn get_snippet(&mut self, name: &str) -> rhai::Dynamic {
    let snippets = self.snippets.borrow_mut();

    match snippets.get(name) {
      Some(snippet) => {
        let mut attributes: HashMap<rhai::ImmutableString, rhai::Dynamic> = HashMap::default();
        for (k,v) in snippet.attributes.clone().drain() {
          attributes.insert(k.into(), v.into());
        }

        let mut out: HashMap<rhai::ImmutableString, rhai::Dynamic> = HashMap::default();
        out.insert("content".into(), snippet.get_raw_content("\n").into());
        out.insert("attrs".into(), attributes.into());

        out.into()
      },
      None => rhai::Dynamic::from(()),
    }
  }

  pub fn get_snippet_names(&mut self) -> rhai::Array {
    let mut snippets = self.snippets.borrow_mut();

    let mut out = rhai::Array::new();

    let mut keys = snippets
      .iter()
      .map(|(key, _)| { key.to_string() })
      .collect::<Vec<_>>();
    keys.sort();
    let out: rhai::Array = keys
      .into_iter()
      .map(|key| { key.into() })
      .collect();

    out
  }
}
1 Eine wichtige Anwendung von Pipe-Snippets ist sich selbst dynamisch zu schreiben (TODO link). Damit das klappt muss der bestehende Inhalt ersetzt werden (Und nicht angehängt).
use core::cell::RefCell;
use std::rc::Rc;

Umgang mit Fehlern beim Raussuchen der Schnipsel

Es kann vorkommen, dass der Benutzer ein Schnipsel referenziert, welches er nie definiert. Das zeigt sich dadurch, dass eine Dependency fehlt.

TODO

Seiteneffekte (Zugriff auf die Betriebsystem-Umgebung)

Lisi kann Seiteneffekte nutzen (Das ist sogar eine der Hauptaufgaben von lisi). Das bedeutet es erzeugt und nutzt Ein- und Ausgaben welche nicht direkt als Parameter übergeben wurden.

Das Schreiben von Dateien im Dateisystem, das Ausführen von Scripten durch externe Interpreter, usw sind alles Seiteneffekte.

Das ist sehr schön und einer der Gründe, warum Lisi so mächtig ist, doch in manchen Situationen kann es auch zu Problemen führen:

  • Beim Testen (TODO link) müssen wir wissen welche Seiteneffekte genutzt werden (und auf welche Art).

  • Wollen wir lisi in eingeschränkten Umgebungen nutzen (embedded Kontext, WASM) stehen uns viele dieser betriebssystemabhängigen Seiteneffekte nicht zur Verfügung und wir müssen Alternative Wege finden sie zu implementieren.

TODO Die Seiteneffekt API beschreiben sowie dependency injektion. Eventuell auch in asciidoctrine nutzen (z.B. für import)

env: asciidoctrine::util::Env,
env: util::Env::Io(util::Io::new()),
use asciidoctrine::util::Environment;

Manchmal (insbesondere bei Tests TODO link) müssen wir auf die Seiteneffekte zugreifen können. Dafür verwenden wir eine spezielle Funktion, welche das Environment bei der Initialisierung überschreibt.

pub fn from_env(env: util::Env) -> Self {
  let mut base = Lisi::new();
  base.env = env;

  base
}
pub fn into_cache(self) -> Option<HashMap<String, String>> {
  self.env.get_cache()
}

Fehlerbehandlung

Um Fehler abfangen zu können benutzen wir das thiserror crate.

thiserror = "1.0"
#[derive(thiserror::Error, Debug)]
pub enum Error {
  <<errors>>
}

Das betrifft alles Fehler, welche so von der Bibliothek nicht abgefangen werden. Es gibt allerdings auch Fehler, welche erst zur Laufzeit vom Programm abgefangen werden. Für diese benötigen wir einen Logging Mechanismus.

log = "0.4"
simple_logger = "5"
#[macro_use]
extern crate log;

Das resultierende lisi executable soll allerdings alle Arten von Fehlern abfangen, deshalb verwenden wir hier den anyhow crate.

anyhow = "1.0"

Tests

Um lisi zu testen verwenden wir die in diesem Dokument beschriebenen Beispiele aus der Bedienungsanleitung und überprüfen, ob die Ergebnisse wirklich generiert werden.

Ein Test hat dabei grundsätzlich folgenden Aufbau:

Grundlegender Aufbau eines Unit Tests
#[test]
fn {test_name}() -> Result<()> {
  let content = r#"{asciidoc_content}"#;
  let reader = AsciidocReader::new();
  let opts = options::Opts::parse_from(vec![""].into_iter());
  let mut env = util::Env::Cache(util::Cache::new());
  let ast = reader.parse(content, &opts, &mut env)?;

  let mut lisi = Lisi::from_env(env);
  let _ast = lisi.transform(ast)?;

  // TODO ast vergleichen

  let mut outputs = lisi.into_cache().unwrap();

  {compare_outputs}

  assert!(outputs.is_empty()); (1)

  Ok(())
}
1 Nachdem wir unsere erzeugten Dateien überprüft haben, müssen wir noch überprüfen, ob keine weiteren Dateien erzeugt wurden welche von unserem Test nicht abgedeckt wurden.

Das Überprüfen des Inhaltes einer erzeugten Datei läuft immer gleich ab: Wir holen den erzeugten Text aus dem Ausgabepuffer mit dem entsprechenden Dateinamen und vergleichen ihn mit dem dazugeörigen Snippet aus der Dokumentation.

Den Inhalt eines generierten Textes überprüfen
assert_eq!(
  outputs.remove("{filepath}").unwrap(),
  r#"{expected_content}"#
);

Unsere Tests packen wir in eine Test Umgebung, welche alle wichtigen crates bereits importiert.

tests/lisi_test.rs
1
2
3
4
5
6
7
use anyhow::Result;
use asciidoctrine::{self, *};
use clap::Parser;
use lisi::*;
use pretty_assertions::assert_eq;

<<lisi-unit-tests>>

Um nun die eigentlichen Tests zu erzeugen müssen wir nur noch alle Beschreibungen (Snippets) aus der Benutzerdokumentation extrahrieren und überprüfen, ob die dort versprochenen AUsgaben auch wirklich erzeugt wurden. Um die entsprechenden Snippets zu finden haben wir sie im Asciidoc Text mit Attributen gekennzeichnet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
let template = lisi.get_snippet("unit-test-template").content;
template.replace(" // <1>", "");
let compare_template = lisi.get_snippet("unit-test-compare_outputs-template").content;
let tests = "";

fn split(input, seperator) {
  let out = [];
  let start = 0;
  let idx = input.index_of(seperator);
  while idx > -1 {
    let chunk = input.sub_string(start, idx - start);
    out.push(chunk);

    start = idx + 1;
    idx = input.index_of(seperator, start);
  };
  out.push(input.sub_string(start));
  return out;
}

for name in lisi.get_snippet_names() {
  if name.contains("unittest_") && name.contains("_input") {
    let out_template = template;
    let compare_out = "";

    let snippet = lisi.get_snippet(name);
    out_template.replace("{test_name}", snippet.attrs.name);
    let asciidoc_content = "\n" + snippet.content + "\n";
    asciidoc_content.replace(" -- <1>", "");
    asciidoc_content.replace(" -- <2>", "");
    out_template.replace("{asciidoc_content}", asciidoc_content);
    let outputs_names = snippet.attrs.outputs;

    for output_name in split(outputs_names, ",") {
      let compare_out_template = compare_template;
      let output_snippet = lisi.get_snippet(output_name);
      compare_out_template.replace("{expected_content}", output_snippet.content + "\n");
      compare_out_template.replace("{filepath}", output_snippet.attrs.title);
      compare_out += compare_out_template + "\n";
    }

    out_template.replace("{compare_outputs}", compare_out);
    tests += out_template;
    tests += "\n\n";
  }
}

lisi.store("lisi-unit-tests", tests);

Um schönere diffs angezeigt zu bekommen sobald ein Fehler auftritt verwenden wir den pretty_assertions crate.

pretty_assertions = "1"

Zudem brauchen wir die clap Bibliothek um die Kommandozeilenparameter zu parsen.

clap = { version = "4", features = ["derive"] }

Installation

Build

Da das ganze eine rust Bibliothek ist brauchen wir eine Cargo.toml Datei damit das Programm (und die Bibliothek) kompiliert werden können.

Cargo.toml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[package]
name = "lisi"
version = "0.2.0"
description = "literate programming with asciidoc"
readme = "lisi.adoc"
homepage = "https://kober-systems.github.io/literate_programming_toolsuite/lisi/lisi.html"
repository = "https://github.com/kober-systems/literate_programming_toolsuite"
authors = ["Benjamin Kober <benko@kober-systems.com>"]
edition = "2018"
license = "MIT"
keywords = ["literate-programming", "asciidoc", "cli"]
categories = ["command-line-utilities", "development-tools"]
include = [
  "**/*.rs",
  "**/*.pest",
  "Cargo.toml",
]

[dependencies]
<<cargo_dependencies>>

[dev-dependencies]
<<cargo_dev_dependencies>>