Commit 84f30dad authored by Matthias Simon's avatar Matthias Simon
Browse files

Add tool

parent ce84a922
Loading
Loading
Loading
Loading

tools/README.md

0 → 100644
+61 −0
Changes for tools/README.md: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Tools

The purpose of this tool is to automate common tasks around writing
the TTCN-3 standard.

The markdown documents are the single source of truth. They rely on custom
conventions to make automation and maintenance simpler to achieve.

## Features

- [ ] Format TTCN-3 code blocks inplace.
- [ ] Extract TTCN-3 code blocks
- [ ] Extract TTCN-3 grammar
- [ ] Code Linting
- [ ] DOCX export
- [ ] DOCX import

### Extract TTCN-3 code blocks

- Extract all TTCN-3 code blocks into individual files (`./examples/`)
- the filename includes the clause (e.g. `./examples/10.1.2-example1.ttcn3`)
- Meta-formation is copied into the file as TTCN-3 comment. For example:

    // @title: Assignment notation leaves elements unchanged
    // @clause: 10.1.2

### Extract TTCN-3 grammar

- Extract all EBNF code blocks into a single file containing the TTCN-3 grammar (`ttcn3.ebnf`).
- Extract all keywords from the grammar (`keywords.txt`)
- Extract all modifiers from the grammar (`modifiers.txt`)

### Code Linting

- Verify that all EBNF productions are reachable and defined.
- Verify that all TTCN-3 examples parse.
- Error messages shall provide the correct line-number in the markdown-file

### DOCX Export

Compile and export markdown documents into a coherent DOCX document that adheres
to ETSI requirements.

- ETSI template in @assets
- code blocks have syntax highlighting
- references to EBNF productions are hyperlinks referring to their definition.
- Every example has its title preceding using the "EX"-Style. E.g. `EXAMPLE 1: assignment notation`

### DOCX Import

During our migration it will be neccessary to import existing word documents
and canonicalize them:

- Paragraphs "Syntactical description" will become fenced code block of type "syntax"
- Paragraphs with EBNF rules have type "ebnf"
- TTCN-3 code paragraphs have type "ttcn3"
- The title of a TTCN-3 example will be part of the fence: ```ttcn3 example="assignment notation
- Item-lists will be compacted (no extra newline between them)
- One Sentence per line.
- Long lines are wrapped on natural boundaries; first on punctuation, then on words
+126 −0
Changes for tools/internal/doc/doc.go: 126 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Package doc is a format-neutral model of an ETSI deliverable.
//
// Readers in sibling packages, such as doc/md, build a Document. Tasks such
// as extraction, linting, and export work on the Document only.
package doc

// Pos is a position in a source file. Line is 1-based.
//
// In Markdown, Line is the source line. In a DOCX file, Line is the index of
// the paragraph in document order. Paragraphs that are skipped, and
// paragraphs inside tables, are counted too.
type Pos struct {
	File string
	Line int
}

// Document is the whole deliverable, possibly read from many files.
type Document struct {
	Sections []*Section
}

// Section is a numbered clause or annex.
type Section struct {
	Pos
	Number   string // "5", "5.3.1", "19.1", "A", "A.2"
	Level    int    // number of components in Number
	Title    []Inline
	Blocks   []Block
	Sections []*Section
}

// Block is one of Paragraph, List, Quote, Note, Code, Comment, or Table.
type Block interface{ block() }

// Inline is one of Text, Emph, Strong, InlineCode, Link, Ref, SoftBreak, or
// HardBreak.
type Inline interface{ inline() }

type Paragraph struct {
	Pos
	Content []Inline
}

type List struct {
	Pos
	Ordered bool
	Start   int
	Items   [][]Block
}

type Quote struct {
	Pos
	Blocks []Block
}

// Note is an ETSI note. The "NOTE:" or "NOTE <n>:" label is not part of
// Blocks.
type Note struct {
	Pos
	Number string // "" for an unnumbered note
	Blocks []Block
}

// Code is a code block. Pos is the first body line and EndLine the last one,
// excluding fences.
type Code struct {
	Pos
	EndLine int
	Lang    string // "ttcn3", "ebnf", "syntax", "math", ""
	Meta    []Meta // info-string markers, in source order
	Lines   []string
}

// Meta is a code marker such as example="title". Value is empty for a bare
// marker.
type Meta struct {
	Key, Value string
}

// Comment is an HTML comment block, without the comment delimiters.
type Comment struct {
	Pos
	Text string
}

// Table is a grid. Rows[0] is the header row. A cell is a sequence of
// inlines; a hard break separates paragraphs that shared a cell.
type Table struct {
	Pos
	Rows [][][]Inline
}

func (*Paragraph) block() {}
func (*List) block()      {}
func (*Quote) block()     {}
func (*Note) block()      {}
func (*Code) block()      {}
func (*Comment) block()   {}
func (*Table) block()     {}

type Text string
type Emph []Inline
type Strong []Inline
type InlineCode string

type Link struct {
	URL     string
	Content []Inline
}

// Ref is a bracketed term without a link definition, such as [variables].
type Ref struct {
	Term string
}

type SoftBreak struct{}
type HardBreak struct{}

func (Text) inline()       {}
func (Emph) inline()       {}
func (Strong) inline()     {}
func (InlineCode) inline() {}
func (Link) inline()       {}
func (Ref) inline()        {}
func (SoftBreak) inline()  {}
func (HardBreak) inline()  {}
+287 −0
Changes for tools/internal/doc/docx/inline.go: 287 added lines, 0 removed lines.
Original line number Diff line number Diff line
package docx

import (
	"strings"
	"unicode"

	"tools/internal/doc"
)

// span is a run of text with one formatting, a hyperlink target, or a break.
type span struct {
	text               string
	bold, italic, mono bool
	url                string
	br                 bool
}

type cell struct {
	r                  rune
	bold, italic, mono bool
	url                string
	br                 bool
}

var stopwords = map[string]bool{}

func init() {
	for _, w := range strings.Fields("a an the of to for and or if in on as by be is it at do") {
		stopwords[w] = true
	}
}

// spansToInlines merges runs, drops emphasis-marker debris, and joins a
// formatting change that falls inside a word.
func spansToInlines(spans []span) []doc.Inline {
	cells := expand(spans)
	cells = dropDebris(cells)
	joinWords(cells)
	return rebuild(cells)
}

func expand(spans []span) []cell {
	var out []cell
	for _, s := range spans {
		if s.br {
			out = append(out, cell{br: true})
			continue
		}
		for _, r := range s.text {
			out = append(out, cell{r: r, bold: s.bold, italic: s.italic, mono: s.mono, url: s.url})
		}
	}
	return out
}

// dropDebris removes bold or italic asterisks that sit against a letter or
// another asterisk. A spaced asterisk, as in "omit, * or ifpresent", stays.
func dropDebris(cells []cell) []cell {
	drop := make([]bool, len(cells))
	for i, c := range cells {
		if c.br || c.mono || c.r != '*' || !(c.bold || c.italic) {
			continue
		}
		if i > 0 && debrisSide(cells[i-1]) || i+1 < len(cells) && debrisSide(cells[i+1]) {
			drop[i] = true
		}
	}
	var out []cell
	for i, c := range cells {
		if !drop[i] {
			out = append(out, c)
		}
	}
	return out
}

func debrisSide(c cell) bool {
	return !c.br && (c.r == '*' || unicode.IsLetter(c.r))
}

// joinWords gives one formatting to a word split across runs. The shorter
// side must be at most three letters and not a whole stopword. The longer
// side's formatting wins. A tie keeps the first side. Letters are not joined
// to digits, and a missing space between longer words is left as it is.
func joinWords(cells []cell) {
	if len(cells) == 0 {
		return
	}
	for pass := 0; pass <= len(cells); pass++ {
		changed := false
		for i := 0; i < len(cells); {
			if !isLetter(cells[i]) {
				i++
				continue
			}
			j := i + 1
			for j < len(cells) && isLetter(cells[j]) && sameFmt(cells[i], cells[j]) {
				j++
			}
			if j >= len(cells) || !isLetter(cells[j]) || !joinable(cells[i], cells[j]) {
				i = j
				continue
			}
			k := j + 1
			for k < len(cells) && isLetter(cells[k]) && sameFmt(cells[j], cells[k]) {
				k++
			}
			left, right := j-i, k-j
			shortStart, shortEnd := i, j
			if right < left {
				shortStart, shortEnd = j, k
			}
			shorter := shortEnd - shortStart
			word := cellsText(cells[shortStart:shortEnd])
			if shorter <= 3 && !stopwords[strings.ToLower(word)] {
				target := cells[i]
				if right > left {
					target = cells[j]
				}
				for t := i; t < k; t++ {
					cells[t].bold = target.bold
					cells[t].italic = target.italic
				}
				changed = true
				i = k
				continue
			}
			i = j
		}
		if !changed {
			return
		}
	}
}

func isLetter(c cell) bool { return !c.br && unicode.IsLetter(c.r) }

func sameFmt(a, b cell) bool {
	return a.bold == b.bold && a.italic == b.italic && a.mono == b.mono && a.url == b.url
}

func joinable(a, b cell) bool {
	return !a.mono && !b.mono && a.url == b.url
}

func cellsText(cells []cell) string {
	var b strings.Builder
	for _, c := range cells {
		b.WriteRune(c.r)
	}
	return b.String()
}

type group struct {
	text               string
	bold, italic, mono bool
	url                string
	br                 bool
}

func rebuild(cells []cell) []doc.Inline {
	var groups []group
	var cur *group
	flush := func() {
		if cur == nil {
			return
		}
		if cur.text != "" {
			groups = append(groups, *cur)
		}
		cur = nil
	}
	for _, c := range cells {
		if c.br {
			flush()
			groups = append(groups, group{br: true})
			continue
		}
		if cur == nil || cur.bold != c.bold || cur.italic != c.italic || cur.mono != c.mono || cur.url != c.url {
			flush()
			cur = &group{bold: c.bold, italic: c.italic, mono: c.mono, url: c.url}
		}
		cur.text += string(c.r)
	}
	flush()

	var out []doc.Inline
	for i := 0; i < len(groups); {
		if groups[i].br {
			out = append(out, doc.HardBreak{})
			i++
			continue
		}
		if groups[i].url != "" {
			url := groups[i].url
			var content []doc.Inline
			for i < len(groups) && !groups[i].br && groups[i].url == url {
				content = append(content, groupInline(groups[i]))
				i++
			}
			out = append(out, doc.Link{URL: url, Content: mergeText(content)})
			continue
		}
		out = append(out, groupInline(groups[i]))
		i++
	}
	return mergeText(out)
}

func groupInline(g group) doc.Inline {
	if g.mono {
		return doc.InlineCode(g.text)
	}
	var in doc.Inline = doc.Text(g.text)
	if g.italic {
		in = doc.Emph{in}
	}
	if g.bold {
		in = doc.Strong{in}
	}
	return in
}

func mergeText(ins []doc.Inline) []doc.Inline {
	var out []doc.Inline
	for _, in := range ins {
		t, ok := in.(doc.Text)
		if !ok || t == "" {
			if ok {
				continue
			}
			out = append(out, in)
			continue
		}
		if n := len(out); n > 0 {
			if prev, ok := out[n-1].(doc.Text); ok {
				out[n-1] = prev + t
				continue
			}
		}
		out = append(out, in)
	}
	return out
}

func concat(spans []span) string {
	var b strings.Builder
	for _, s := range spans {
		if s.br {
			b.WriteByte('\n')
			continue
		}
		b.WriteString(s.text)
	}
	return b.String()
}

func trimSpans(spans []span, n int) []span {
	if n <= 0 {
		return spans
	}
	var out []span
	for _, s := range spans {
		if n <= 0 {
			out = append(out, s)
			continue
		}
		if s.br {
			n--
			continue
		}
		if len(s.text) <= n {
			n -= len(s.text)
			continue
		}
		s.text = s.text[n:]
		n = 0
		if s.text != "" {
			out = append(out, s)
		}
	}
	return out
}

func plain(spans []span) string {
	return strings.TrimSpace(concat(spans))
}
+752 −0

File added.

Preview size limit exceeded, changes collapsed.

+441 −0
Changes for tools/internal/doc/docx/read.go: 441 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Package docx reads an ETSI DOCX file into a doc.Document.
package docx

import (
	"archive/zip"
	"bytes"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"strings"

	"tools/internal/doc"
)

// Read parses one DOCX file and appends its sections to d.
//
// The first heading 1 is numbered by the file name prefix. Deeper headings
// are numbered below it; a number typed into a heading is removed from the
// title and is not used. Tracked deletions are dropped and insertions are
// kept. Review comments are ignored.
func Read(d *doc.Document, path string, src []byte) error {
	z, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
	if err != nil {
		return fmt.Errorf("%s: %w", path, err)
	}
	p, docXML, err := parseParts(z)
	if err != nil {
		return fmt.Errorf("%s: %w", path, err)
	}
	items := p.items(docXML)
	if p.err != nil {
		return fmt.Errorf("%s: %w", path, p.err)
	}
	r := &reader{path: path, items: items}
	r.build(d)
	return errors.Join(r.errs...)
}

type reader struct {
	path  string
	items []item
	errs  []error
}

func (r *reader) errorf(line int, format string, args ...any) {
	r.errs = append(r.errs, fmt.Errorf("%s:%d: %s", r.path, line, fmt.Sprintf(format, args...)))
}

func (r *reader) pos(line int) doc.Pos { return doc.Pos{File: r.path, Line: line} }

type frame struct {
	level    int
	sec      *doc.Section
	b        *builder
	children int
}

type codeBuf struct {
	start, end int
	lines      []string
	meta       []doc.Meta
}

func (r *reader) build(d *doc.Document) {
	var stack []*frame
	var code *codeBuf
	var pending []doc.Meta
	prevPara := ""
	started := false
	used := map[string]bool{}

	top := func() *builder { return stack[len(stack)-1].b }

	flush := func() {
		if code == nil {
			return
		}
		lines := trimBlank(code.lines)
		meta := code.meta
		start, end := code.start, code.end
		code = nil
		pending = nil
		label := prevPara
		prevPara = ""
		if len(lines) == 0 || len(stack) == 0 {
			return
		}
		lang := "ttcn3"
		if syntaxRE.MatchString(strings.TrimSpace(label)) {
			lang = "syntax"
		} else if ebnfRE.MatchString(strings.Join(lines, "\n")) {
			lang = "ebnf"
		}
		top().add(&doc.Code{
			Pos:     r.pos(start),
			EndLine: end,
			Lang:    lang,
			Meta:    meta,
			Lines:   lines,
		})
	}

	for i, it := range r.items {
		if it.table != nil {
			if !started {
				continue
			}
			flush()
			if len(stack) > 0 {
				top().add(r.table(it.table))
			}
			continue
		}
		p := it.p
		if p == nil || p.kind == pkSkip {
			continue
		}
		if !started {
			if p.kind != pkHeading || p.hlevel != 1 {
				continue
			}
			started = true
		}
		if p.kind == pkHeading {
			flush()
			r.heading(d, &stack, p, used)
			continue
		}
		if len(stack) == 0 {
			continue
		}
		if p.kind == pkEX {
			if code != nil {
				flush()
			}
			if followedByPL(r.items, i) {
				pending = []doc.Meta{{Key: "example", Value: exampleTitle(p)}}
				continue
			}
			r.addParagraph(top(), p)
			prevPara = plain(p.spans)
			continue
		}
		if p.kind == pkPL {
			if code == nil {
				code = &codeBuf{start: p.line, meta: pending}
				pending = nil
			}
			code.lines = append(code.lines, paraLines(p.spans)...)
			code.end = p.line
			continue
		}
		flush()
		switch p.kind {
		case pkNO:
			r.note(top(), p)
		case pkList:
			r.listItem(top(), p)
		default:
			if p.image {
				top().add(&doc.Comment{Pos: r.pos(p.line), Text: "TODO: image"})
			}
			if !emptyPara(p) {
				r.addParagraph(top(), p)
				prevPara = plain(p.spans)
			}
		}
	}
	flush()
	if !started {
		r.errorf(1, "no heading")
	}
}

func (r *reader) heading(d *doc.Document, stack *[]*frame, p *paragraph, used map[string]bool) {
	text := concat(p.spans)
	explicit, n := doc.HeadingNumber(text)
	title := spansToInlines(trimSpans(p.spans, n))
	sec := &doc.Section{Pos: r.pos(p.line), Title: title}

	pushRoot := func(num string) {
		if num == "" || used[num] {
			num = freshNumber(used)
		}
		used[num] = true
		sec.Number = num
		sec.Level = doc.Level(num)
		d.Sections = append(d.Sections, sec)
		*stack = []*frame{{level: p.hlevel, sec: sec, b: &builder{sec: sec}}}
	}

	if len(*stack) == 0 {
		num := explicit
		if pre, ok := doc.FilePrefix(r.path); ok {
			num = pre
		}
		if num == "" {
			r.errorf(p.line, "no clause number: file name has no number prefix and heading is not numbered")
			return
		}
		pushRoot(num)
		return
	}
	if p.hlevel <= (*stack)[0].level {
		pushRoot(explicit)
		return
	}
	for len(*stack) > 1 && (*stack)[len(*stack)-1].level >= p.hlevel {
		*stack = (*stack)[:len(*stack)-1]
	}
	parent := (*stack)[len(*stack)-1]
	parent.children++
	sec.Number = fmt.Sprintf("%s.%d", parent.sec.Number, parent.children)
	sec.Level = doc.Level(sec.Number)
	used[sec.Number] = true
	parent.sec.Sections = append(parent.sec.Sections, sec)
	*stack = append(*stack, &frame{level: p.hlevel, sec: sec, b: &builder{sec: sec}})
}

func freshNumber(used map[string]bool) string {
	for n := 1000; ; n++ {
		s := strconv.Itoa(n)
		if !used[s] {
			used[s] = true
			return s
		}
	}
}

func (r *reader) addParagraph(b *builder, p *paragraph) {
	ins := spansToInlines(p.spans)
	if len(ins) == 0 {
		return
	}
	b.add(&doc.Paragraph{Pos: r.pos(p.line), Content: ins})
}

func (r *reader) note(b *builder, p *paragraph) {
	text := concat(p.spans)
	spans := p.spans
	num := ""
	if m := noteRE.FindStringSubmatchIndex(text); m != nil {
		if m[2] >= 0 {
			num = text[m[2]:m[3]]
		}
		spans = trimSpans(spans, m[1])
	}
	n := &doc.Note{Pos: r.pos(p.line), Number: num}
	if ins := spansToInlines(spans); len(ins) > 0 {
		n.Blocks = []doc.Block{&doc.Paragraph{Pos: r.pos(p.line), Content: ins}}
	}
	b.add(n)
}

func (r *reader) listItem(b *builder, p *paragraph) {
	text := concat(p.spans)
	ordered, start := listOrdered(p, text)
	spans := p.spans
	if m := listMarkRE.FindStringIndex(text); m != nil {
		spans = trimSpans(spans, m[1])
	}
	var blocks []doc.Block
	if p.image {
		blocks = append(blocks, &doc.Comment{Pos: r.pos(p.line), Text: "TODO: image"})
	}
	if ins := spansToInlines(spans); len(ins) > 0 {
		blocks = append(blocks, &doc.Paragraph{Pos: r.pos(p.line), Content: ins})
	}
	b.item(p.listLevel, ordered, start, blocks, r.pos(p.line))
}

func (r *reader) table(t *rawTable) *doc.Table {
	tab := &doc.Table{Pos: r.pos(t.line)}
	for _, row := range t.rows {
		var cells [][]doc.Inline
		for _, c := range row {
			cells = append(cells, cellInlines(c.paras))
		}
		tab.Rows = append(tab.Rows, cells)
	}
	return tab
}

func cellInlines(paras []paragraph) []doc.Inline {
	var out []doc.Inline
	for _, p := range paras {
		ins := spansToInlines(p.spans)
		if len(ins) == 0 {
			continue
		}
		if len(out) > 0 {
			out = append(out, doc.HardBreak{})
		}
		out = append(out, ins...)
	}
	return out
}

type builder struct {
	sec   *doc.Section
	lists []*openList
}

type openList struct {
	list    *doc.List
	level   int
	ordered bool
}

func (b *builder) add(blk doc.Block) {
	b.lists = nil
	b.sec.Blocks = append(b.sec.Blocks, blk)
}

func (b *builder) item(level int, ordered bool, start int, blocks []doc.Block, pos doc.Pos) {
	if level < 1 {
		level = 1
	}
	for len(b.lists) > 0 && b.lists[len(b.lists)-1].level > level {
		b.lists = b.lists[:len(b.lists)-1]
	}
	if len(b.lists) > 0 && b.lists[len(b.lists)-1].level == level && b.lists[len(b.lists)-1].ordered != ordered {
		b.lists = b.lists[:len(b.lists)-1]
	}
	if len(b.lists) == 0 || b.lists[len(b.lists)-1].level < level {
		l := &doc.List{Pos: pos, Ordered: ordered}
		if ordered {
			if start <= 0 {
				start = 1
			}
			l.Start = start
		}
		if len(b.lists) == 0 {
			b.sec.Blocks = append(b.sec.Blocks, l)
		} else {
			parent := b.lists[len(b.lists)-1]
			i := len(parent.list.Items) - 1
			parent.list.Items[i] = append(parent.list.Items[i], l)
		}
		b.lists = append(b.lists, &openList{list: l, level: level, ordered: ordered})
	}
	b.lists[len(b.lists)-1].list.Items = append(b.lists[len(b.lists)-1].list.Items, blocks)
}

func followedByPL(items []item, i int) bool {
	for _, it := range items[i+1:] {
		if it.table != nil {
			return false
		}
		p := it.p
		if p == nil || p.kind == pkSkip {
			continue
		}
		if p.kind != pkPL && emptyPara(p) {
			continue
		}
		return p.kind == pkPL
	}
	return false
}

func emptyPara(p *paragraph) bool {
	if p.image {
		return false
	}
	for _, s := range p.spans {
		if s.br || strings.TrimSpace(s.text) != "" {
			return false
		}
	}
	return true
}

func paraLines(spans []span) []string {
	var lines []string
	var b strings.Builder
	flush := func() {
		lines = append(lines, b.String())
		b.Reset()
	}
	for _, s := range spans {
		if s.br {
			flush()
			continue
		}
		parts := strings.Split(strings.ReplaceAll(s.text, "\r\n", "\n"), "\n")
		for i, part := range parts {
			if i > 0 {
				flush()
			}
			b.WriteString(part)
		}
	}
	flush()
	return lines
}

func trimBlank(lines []string) []string {
	for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
		lines = lines[1:]
	}
	for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
		lines = lines[:len(lines)-1]
	}
	return lines
}

func exampleTitle(p *paragraph) string {
	text := plain(p.spans)
	if m := exampleRE.FindStringSubmatch(text); m != nil {
		return strings.TrimSpace(m[1])
	}
	return text
}

func listOrdered(p *paragraph, text string) (bool, int) {
	if m := digitMarkRE.FindStringSubmatch(text); m != nil {
		n, _ := strconv.Atoi(m[1])
		return true, n
	}
	if letterMarkRE.MatchString(text) {
		return true, 1
	}
	switch p.numFmt {
	case "", "bullet", "none":
		return false, 0
	default:
		return true, 1
	}
}

var (
	exampleRE    = regexp.MustCompile(`(?i)^EXAMPLE(?:\s+[0-9]+)?\s*:\s*(.*)$`)
	noteRE       = regexp.MustCompile(`(?i)^NOTE(?:\s+([0-9]+))?\s*:\s*`)
	syntaxRE     = regexp.MustCompile(`(?i)^syntactic(?:al)? (?:structure|description)\.?$`)
	ebnfRE       = regexp.MustCompile(`(?m)^[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]*::=`)
	listMarkRE   = regexp.MustCompile(`^(?:[-•–—][ \t]+|([0-9]+|[A-Za-z])[.)][ \t]*)`)
	digitMarkRE  = regexp.MustCompile(`^([0-9]+)[.)]`)
	letterMarkRE = regexp.MustCompile(`^[A-Za-z][.)]`)
)
Loading