neweb: A Markdown Literate Programming Tool written in Go

Dr. Meik Teßmer1

2024

Zusammenfassung

neweb bietet im Gegensatz zu noweb eine integrierte Implementierung der Tangle- und Weave-Funktionalität sowie eine erweiterte Syntax, die das Syntax Highlighting von vielen Programmiersprachen ermöglicht. An Stelle von LaTeX als Dokumentationssprache werden durch die Verwendung von Markdown und pandoc unterschiedlichste Ausgabeformate unterstützt.

1 Literate Programming

1.1 Der Beginn: CWEB

Knuth beschreibt in Knuth (1992) sein Konzept des Literate Programming. Der wesentliche Unterschied zum „normalen“ Programmieren ist, dass der Quellcode (Code Chunks) eingebettet wird in einen Text (Doc Chunks), der den Aufbau und die Funktionsweise des Programms erläutert. Der Quellcode dient dabei als konkrete Form der Beschreibung. Er kann extrahiert und ggf. kompiliert werden, so dass schließlich ein lauffähiges Programm entsteht.

In Knuths Beschreibung, die er ebenfalls literat verfasst hat, lassen sich folgende Dinge beobachten:

Sein CWEB genanntes System ist eng an LaTeX und die Programmiersprache PASCAL gekoppelt. Mit seiner Hilfe entwickelte Knutz sein -Satzsystem.

1.2 Eine alternative Implementierung: noweb

Ramsey wollte das Literate Programming-Prinzip soweit wie möglich vereinfachen und die Anzahl zu verwendender „Anweisungen“ auf das Nötigste reduzieren. Dazu entwickelte er mit noweb eine vereinfachte Variante der Literate Programming-Konzepts (s. Ramsey 1994).

Ramsey definierte ein LP-Programm als Abfolge von Chunks. Code Chunks beginnen am Zeilenanfang und werden mit <<...>>= ausgezeichnet. Jeder Code Chunk ist benannt, wobei eine wiederholte Verwendung desselben Bezeichners eine Fortführung eines vorherigen darstellt. Dies wird im Zielformat durch ein +≡ angezeigt. Alles nach der beginnenden Zeile gehört zu Code Chunk, bis hin zu einer neuen Chunk-Definition. Dies kann wieder ein Code Chunk sein oder ein Doc Chunk.

In einem Code Chunk kann auf andere Code Chunks verwiesen werden:

 <<A>>=
 ...

 <<B>>=
 a = 0
 <<A>>
 b = 1
 ...

Doc Chunks werden mit einem @ gefolgt von einem Leerzeichen eingeleitet und sind unbenannt. Ein Doc Chunk reicht ebenfalls bis zur nächsten Chunk-Definition (Code oder Doc Chunk).

Wenn kein expliziter „Start“-Code-Chunk beim Aufruf von tangle angegeben wird, wird <<*>>= angenommen.

Ein nach einem Code Chunk folgender Doc Chunk kann zusätzliche Informationen zu diesem enthalten, bspw. Angaben zu Bezeichnern:

 a = len(b)
 @ %def a b
 Hier wird a ...
 ...

Der Inhalt des Doc Chunks beginnt dann erst in der Folgezeile.

Anders als Knuth orientierte Ramsey die Code Chunks für die Navigation an den Seitenzahlen, auf denen sie stehen. Befindet sich mehr als ein Code Chunk auf einer Seite, werden Buchstaben angehängt. Ein Beispiel: Auf Seite 5 werden zwei Code Chunks aufgeführt. Ihre „Positionen“ lauten dann 5a und 5b. Die Positionsangaben werden auf der linken Seite neben der Code Chunk-Zeile angegeben. Auf der rechten Seite der Zeile stehen Querreferenzen:

 5b ⟨Global Variablen⟩≡                    (6b)  ◁ 5a  6a ▷
   URL = "https://www.heise.de"
   ...

In Klammern befindet sich die Position desjenigen Code Chunks, in dem der aktuelle Code Chunk verwendet wird. Darauf wird durch einen zusätzlichen Hinweis unterhalb des Code Chunks nochmals hingewiesen („This code is used in chunk 6b.“). Weiterhin ist der Vorgänger- und der Folge-Code Chunk angegeben (5a bzw. 6a).

Zusätzlich zu einer Angabe der Verwendung des Code Chunks ermittelt noweb Bezeichner im Code und ihre Verwendung(en). Eine Beispielausgabe umfasst also:

 Defines:
   argc, used in chunks 00c and 99d.
   main, never used.
 Uses prog_name 98d and status 98d.
 This code is used in chunk 99a.

Die Ermittlung von Bezeichnern ist abhängig von der verwendeten Programmiersprache und lässt sich nur bedingt automatisieren. Manuelle Unterstützung ist durch die Verwendung des schon angeführten %def möglich.

Weitere mögliche Zusatzinformationen zu den Code Chunks sind (z. T. redundant zur Randauszeichung):

Am Ende des Dokuments befinden sich weiterhin eine Liste der definierten Chunks sowie ein Index der gefundenen bzw. per %def angegebenen Bezeichner.

Vergleicht man CWEB und noweb, so scheint die Verwendung von Seitenzahlen die Navigation im Dokument einfacher und zugänglicher zu sein als die Verwendung von Abschnittsnummern wie bei Knuth. Die nur zwei Auszeichnungsmethoden für die beiden Chunk-Arten erleichtern zudem den Einstieg in das literate Programmieren.

1.3 Beschränkungen von noweb

Aus heutiger Sicht weist das noweb-Konzept und seine Implementierung mehrere Beschränkungen auf:

Die seitenorientierte Benennung von Code Chunks macht bei einer HTML-Ausgabe keinen Sinn, da hier ein Seitenkonzept nicht vorgesehen ist.

Ein weiteres Problem ist die used in-Nennung. Wenn das literate Programm nur aus einem Modul mit global eindeutigen Bezeichnern bestünde, könnte eine einfache Map-basierte Zuordnung funktionieren. Bei größeren Programmen mit mehrfach genutzten Bezeichnern (bspw. i, pairs usw.) kann die Mehrdeutigkeit nur aufgelöst werden, indem das Programm selbst analysiert wird.

1.4 Vorschläge zur Verbesserung

Um sowohl die Tipparbeit zu verringern als auch Neulingen den Einstieg zu erleichtern, könnte Markdown an Stelle von LaTeX als Doc Chunk-Format genutzt werden. Damit ergibt sich die zudem die Möglichkeit, mit Hilfe von pandoc verschiedenste Ausgabeformate erstellen zu können.

pandoc erlaubt in seiner Markdown-Implementiernung bei sog. Fenced Code Blocks Angaben zur verwendeten Programmiersprache und so ein effektives Syntax Highlighting. Eine verbesserte Version von noweb benötigt der Definition von Code Chunks eine Syntaxerweiterung, die eine solche Angabe ermöglicht.

Eine neue Implementierung sollte sich nicht über mehrere ausführbare Dateien erstrecken, sondern bin Form einer einzigen ausführbaren Datei erfolgen. Zudem sollte es statisch gelinkt sein, um keinerlei externe Abhängigkeiten zu Bibliotheken o.ä. mitzuführen.

Go bietet sich hier aus mehreren Gründen als Programmiersprache für die Implementierung an:

  1. Mit Go lassen sich Programme direkt für mehrere Zielplattformen bauen, so dass eine breite Masse an Betriebssystemen abgedeckt werden kann.
  2. Die Sprache bietet gute Parallelisierungsmöglichkeiten, die u.U. bei der Implementierung helfen können. Das kann die ohnehin schon hohe Geschwindigkeit des Binaries ggf. weiter erhöhen.

1.5 Moderne Implementierung: neweb

neweb ist kompatibel mit noweb, d.h. die von noweb benutzte Chunk-Syntax wird wie gewohnt verarbeitet. neweb erweitert die Definition von Code Chunks: Abgetrennt mit einem Leerzeichen kann in Klammern die in diesem Chunk vorkommende Programmiersprache spezifiziert werden. Weiterhin ist ein Leerzeichen nach Beginn eines Doc Chunks zwingend erforderlich; bei Verwendung der %def-Angaben geschieht das ohnehin schon. Ein Grund für dieses Erfordernis ist die Verwendung von @ in der ersten Spalte durch einige Programmiersprachen. So nutzt es bspw. Python für Decorators.

Ein Beispiel mit hervorgehobenem Leerzeichen beim ersten Doc Chunk:

 <<A>>= (python)
 # meine Python-Funktion
 def fn():
   pass
 @␣

 <<B>>= (go)
 a := 0
 @ %def a

 <<A>>
 b = 1
 @ %def b

2 Implementierung

Die Implementierung von neweb folgt einer klassischen Pipe-Filter-Struktur, in der die Daten von einem Filter zum nächsten geleitet und verarbeitet werden. Für den Weave-Prozess bedeutet das:

.nw-Datei->Parser->[(Doc, Code), (Doc, Chunk),...]-> Formatter->Markdown-Datei

Die Markdown-Datei kann anschließend mit pandoc in das gewünschte Zielformat konvertiert werden.

Der Tangle-Prozess setzt ebenfalls auf die Liste der Chunk-Paare auf:

.nw-Datei->Parser->[(Doc, Code), (Doc, Chunk),...]->Tangler->[Code-Datei, Code-Datei, ...]

2.1 Hauptfunktion und Verarbeiten von Argumenten

Single binary mit symlink nach tangle

0 ⟨Hauptfunktion⟩≡ (1)

func main() {
    // add -v: version
    versionFlag := flag.Bool("v", false, "print version")

    // dispatch tangle and weave
    switch prg := path.Base(os.Args[0]); prg {
    case "neweave":
        flag.Usage = func() {
            fmt.Printf("Usage of %s:\n", os.Args[0])
            fmt.Printf("  %s < x.nw > x.md\n", os.Args[0])
        }
        flag.Parse()

        if *versionFlag {
            fmt.Printf("%s %s\n", name, version)
            os.Exit(0)
        } else {
            reader := bufio.NewReader(os.Stdin)
            writer := bufio.NewWriter(os.Stdout)
            neweb.Weave(reader, writer)
        }
    case "newtangle":
        var startChunk string
        flag.StringVar(&startChunk, "R", "*", "chunk name to start with")
        flag.Parse()

        if *versionFlag {
            fmt.Printf("%s %s\n", name, version)
            os.Exit(0)
        } else {
            reader := bufio.NewReader(os.Stdin)
            writer := bufio.NewWriter(os.Stdout)
            neweb.Tangle(reader, writer, startChunk)
        }
    default:
        fmt.Fprintf(os.Stderr, "unknown program name\n")
        os.Exit(-2)
    }
}
This code is used in chunk 1.

Das Programm:

1 ⟨neweave_new.go⟩≡

package main

import (
    "bufio"
    "flag"
    "fmt"
    "os"
    "path"

    "neweave/neweb"
)

const (
    name    = "neweave"
    version = "0.5.1"
)

<<Hauptfunktion>>

2.2 Dateien lesen und schreiben

Das bufio-Paket bietet verschiedene Funktionen, mit denen eine Datei eingelesen werden kann. Unser Ziel ist, eine Datei zeilenweise, also anhand von \n-Zeichen separiert, einzulesen. Dazu biete sich das Interface Scanner an. Ein Scanner läuft über zu definierende Tokens einer Datei; voreingestellt ist praktischerweise das Newline-Zeichen als Separierer.

Erstellen der Dokumentation

Beispiel einer Makefile-Datei:

NW_FILES=your.nw files.nw
# set your module name
PACKAGE=your_project_name
# set your files to tangle
CODE=$(PACKAGE)/module.py $(PACKAGE)/wrapper.py

MD_FILES=$(patsubst %.nw,%.md,$(NW_FILES))
PDF=$(patsubst %.nw,%.pdf,$(NW_FILES))
HTML=$(patsubst %.nw,%.html,$(NW_FILES))

dirs:
    @for dir in $(CODE); do \
        d=$$(dirname $$dir); \
        [ -d "$$d" ] || mkdir -p "$$d"; \
    done

# generate a PDF
doc: docs/$(PACKAGE).pdf

docs/$(PACKAGE).md: $(NW_FILES)
    cat $^ | neweave > $@

docs/$(PACKAGE).tex: docs/$(PACKAGE).md
    cp bluecat.png literature.bib docs
    pandoc -t latex --standalone --biblatex -o $@ \
        --pdf-engine=xelatex --number-section \
        --citeproc --toc \
        -V biblatexoptions='backend=biber,style=verbose-ibid' \
        -V classoption="toc" -V biblio-style="alphabetic" \
        $<

docs/$(PACKAGE).pdf: docs/$(PACKAGE).tex
    cd docs && xelatex $(notdir $<) && \
        biber $(patsubst %.tex,%,$(notdir $<)) && \
        xelatex $(notdir $<) && xelatex $(notdir $<)

# generate HTML
docs/$(PACKAGE).html: docs/$(PACKAGE).md
    pandoc -o $@ --citeproc --standalone --embed-resources \
        --number-section --toc --highlight-style=pygments $<

# generate code files
code: dirs $(NW_FILES)
    @for file in $(CODE); do \
        cat $(NW_FILES) | newtangle -R "$$file" > "$$file"; \
    done 


clean:
    @rm -rf dist
    @rm -rf $(wildcard docs/*)
    @rm -rf $(PACKAGE)

.PHONY: doc dirs dist code tests clean

3 Analyser

2 ⟨neweb/analyser_new.go⟩≡

// Copyright 2020 by Meik Teßmer. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package neweb

import (
    "regexp"
    "strings"
)

type Analyser struct {
    reCodeChunkUse *regexp.Regexp
}

func NewAnalyser() *Analyser {
    a := new(Analyser)
    a.reCodeChunkUse = regexp.MustCompile(`<` + `<([^>]+)>` + `>`)

    return a
}

// Analyse takes a list of (Doc, Code) chunk pairs and adds missing code chunk
// attributes:
//
// * name
// * (optional): language hint
// * sequential number
// * number of previous/next chunk (code chunk continuations)
// * references to other code chunks (uses)
//
// Additionally it returns two maps:
//
//  1. Sequence of code chunks (for continued code chunks):  Code chunk `x`
//     is distributed over chunks `[1, 4, 5]` (type is `map[string](string.go)[]int`).
//  2. map of use references: code chunk "x" uses code chunks [1,3,5]
//     (type is `map[string][]int`)
//
// It is possible that a code is literally empty (a doc chunk followed
// immediately a previous doc chunk), so Analyse cannot extract a name or
// similar.
func (a *Analyser) Analyse(pairs []ChunkPair) (map[string][]int, map[string][]int) {
    // use map
    // "name" is used like < <...> > in chunks [1, 3, 6]
    codeChunkUseMap := make(map[string][]int, 0)

    // sequence/continuations
    // 1 <-> 5 <-> 6
    codeChunkSequenceMap := make(map[string][]int, 0)

    // not needed; we can use the for loop index
    //chunkCounter := 0

    c := NewChunkifier()

    for number, v := range pairs {
        v.codeChunk.number = number
        // Set name and lang to empty strings if the code chunk is
        // empty.
        if v.codeChunk.IsEmpty() {
            v.codeChunk.name = ""
            v.codeChunk.lang = ""
        } else {
            v.codeChunk.name, v.codeChunk.lang = c.extractNameAndLang(v.codeChunk.lines[0])

            // Complete code chunk sequence map
            //
            // Get the list of code chunk numbers for this code chunk...
            chunkIndices, ok := codeChunkSequenceMap[v.codeChunk.name]
            if ok != true {
                // Ok, this is a new chunk.
                codeChunkSequenceMap[v.codeChunk.name] = make([]int, 0)
                codeChunkSequenceMap[v.codeChunk.name] = append(codeChunkSequenceMap[v.codeChunk.name], number)

            } else {
                // This code chunk is a continuation.
                codeChunkSequenceMap[v.codeChunk.name] = append(chunkIndices, number)
            }

            // Complete code chunk reference map
            a.findUses(number, v.codeChunk.lines, codeChunkUseMap)
        }
    }
    return codeChunkSequenceMap, codeChunkUseMap
}

// findUses searches the raw lines of a code chunk for references to other
// code chunks and adds their sequential number to `useMap`.
func (a *Analyser) findUses(chunkNumber int, lines []string, useMap map[string][]int) {
    // Join all lines but the first (contains the chunk definition) and
    // search for all refs.
    joined_lines := strings.Join(lines[1:], " ")
    matches := a.reCodeChunkUse.FindAllString(joined_lines, -1)
    chunkName := ""
    for _, match := range matches {
        // Split off < < and > >.
        chunkName = match[2 : len(match)-2]
        _, ok := useMap[chunkName]
        if ok != true {
            useMap[chunkName] = make([]int, 0)
        }
        useMap[chunkName] = append(useMap[chunkName], chunkNumber)
    }
}

3 ⟨neweb/analyser_test_new.go⟩≡

// Copyright 2020 by Meik Teßmer. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package neweb

import (
    "testing"
)

func TestNewAnalyser(t *testing.T) {
    a := NewAnalyser()
    if a == nil {
        t.Errorf("cannot create Analyser instance")
    }
}

func TestFindUses(t *testing.T) {
    a := NewAnalyser()

    useMap := make(map[string][]int, 0)
    lines := []string{"<<a>>=", "<<use 1>>", "no use", "<<use 2>> <<use 3>>"}

    a.findUses(0, lines, useMap)

    if v := useMap["use 1"][0]; v != 0 {
        t.Errorf("use 1 should be 0: %d", v)
    }
    if v := useMap["use 2"][0]; v != 0 {
        t.Errorf("use 1 should be 0: %d", v)
    }
    if v := useMap["use 3"][0]; v != 0 {
        t.Errorf("use 3 should be 0: %d", v)
    }
}

func TestAnalyse(t *testing.T) {
    a := NewAnalyser()

    // mockup
    //
    // * The sequence is [0: code chunk 1, 1: code chunk 2].
    // * The first code chunk (0) uses `chode chunk 2`.
    dc1 := NewDocChunk()
    dc1.Append("first line")
    dc1.Append("second line")
    cc1 := NewCodeChunk()
    cc1.Append("<<code chunk 1>>= (python)")
    cc1.Append("hello")
    cc1.Append("<<code chunk 2>>")
    cp1 := NewChunkPair(dc1, cc1)

    dc2 := NewDocChunk()
    dc2.Append(" third line")
    cc2 := NewCodeChunk()
    cc2.Append("<<code chunk 2>>= (python)")
    cc2.Append("world")
    cp2 := NewChunkPair(dc2, cc2)

    chunk_pairs := make([]ChunkPair, 0)
    chunk_pairs = append(chunk_pairs, cp1, cp2)

    seq, use := a.Analyse(chunk_pairs)

    if v := seq["code chunk 1"][0]; v != 0 {
        t.Errorf("sequence is wrong, should be zero: %d", v)
    }
    if v := use["code chunk 2"][0]; v != 0 {
        t.Errorf("chunk 2 is used in chunk 1 (no. 0): %d", v)
    }
}

4 Anhang

4.1 Code Chunks

Hauptfunktion
0
neweave_new.go
1
neweb/analyser_new.go
2
neweb/analyser_test_new.go
3

Literatur

Knuth, Donald E. 1992. Literate Programming. Cambridge University Press.
Ramsey, Norman. 1994. „Literate Programming simplified“. IEEE Software.

  1. <meik.tessmer@.uni-bielefeld.de> ↩︎