Commit d57bbcb6 authored by Matthias Simon's avatar Matthias Simon
Browse files

Generate docx, html and ttcn3

parent 3d4e0214
Loading
Loading
Loading
Loading
+8 −2
Original line number Diff line number Diff line
@@ -2,9 +2,13 @@
.PHONY: all ## generate everything
all:

.PHONY: install ## build and install tools
.PHONY: install ## build the ebnf CLI (output: tools/etsi/ebnf)
install:
	cd tools/etsi && go get && go install
	cd tools/etsi && go build -o etsi .

.PHONY: clean ## remove build artifacts
clean:
	@rm -rfv build/

LANGUAGE_SOURCES := $(wildcard src/*.md)
TARGET_LANGUAGE_MD := build/language.md
@@ -15,6 +19,8 @@ $(TARGET_LANGUAGE_MD): $(LANGUAGE_SOURCES)

TARGET_EBNF := assets/ttcn3.ebnf
$(TARGET_EBNF): $(TARGET_LANGUAGE_MD)
	@mkdir -p $(dir $@)
	cd tools/std && go run . ../../$(TARGET_LANGUAGE_MD) > ../../$(TARGET_EBNF)



templates/section.docx

0 → 100644
+22.6 KiB

File added.

No diff preview for this file type.

tools/etsi/extract.go

0 → 100644
+252 −0
Original line number Diff line number Diff line
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"unicode"
	"unicode/utf8"

	"etsi/internal/markdown"

	"github.com/spf13/cobra"
)

var exampleFenceAttr = regexp.MustCompile(`(?i)\bexample\s*=\s*"([^"]*)"`)
var sectionSlugSanitizer = regexp.MustCompile(`[^a-zA-Z0-9\._-]`)

type extractedTTCN3File struct {
	sectionDir string
	name       string
	content    string
}

func newGenerateTTCN3Cmd() *cobra.Command {
	var outputDir string
	cmd := &cobra.Command{
		Use:   "ttcn3 <markdown-file>",
		Short: "Extract TTCN-3 code blocks into files",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			in, err := filepath.Abs(args[0])
			if err != nil {
				return err
			}
			b, err := os.ReadFile(in)
			if err != nil {
				return err
			}
			files, err := extractTTCN3Files(string(b))
			if err != nil {
				return err
			}
			out, err := filepath.Abs(outputDir)
			if err != nil {
				return err
			}
			return writeExtractedTTCN3Files(out, files)
		},
	}
	cmd.Flags().StringVarP(&outputDir, "output-dir", "o", ".", "output directory for extracted TTCN-3 files")
	return cmd
}

func extractTTCN3Files(src string) ([]extractedTTCN3File, error) {
	p := markdown.Parser{
		HeadingID:     true,
		Strikethrough: true,
		TaskList:      true,
		Table:         true,
		Footnote:      true,
	}
	doc := p.Parse(src)
	w := extractorWalker{}
	if err := w.walk(doc.Blocks); err != nil {
		return nil, err
	}
	return w.files, nil
}

func writeExtractedTTCN3Files(outputDir string, files []extractedTTCN3File) error {
	for _, file := range files {
		dir := filepath.Join(outputDir, file.sectionDir)
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
		path := filepath.Join(dir, file.name)
		if err := os.WriteFile(path, []byte(file.content), 0o644); err != nil {
			return err
		}
	}
	return nil
}

type extractorWalker struct {
	sectionDir string
	ttcn3N     int
	exampleN   int
	files      []extractedTTCN3File
}

func (w *extractorWalker) walk(blocks []markdown.Block) error {
	for _, blk := range blocks {
		switch blk := blk.(type) {
		case *markdown.Heading:
			w.exampleN = 0 // keep reset behavior aligned with the docx generator.
			w.sectionDir = slugSection(inlinesText(blk.Text.Inline))
		case *markdown.CodeBlock:
			lang, attrs := parseFenceInfo(blk.Info)
			if lang != "ttcn3" {
				continue
			}
			if w.sectionDir == "" {
				return fmt.Errorf("ttcn3 block at line %d has no preceding heading", blk.StartLine)
			}
			name := ""
			if attrs == "" {
				w.ttcn3N++
				name = fmt.Sprintf("ttcn3-%03d.ttcn3", w.ttcn3N)
			} else {
				w.exampleN++
				name = fmt.Sprintf("example_%03d.ttcn3", w.exampleN)
			}
			w.files = append(w.files, extractedTTCN3File{
				sectionDir: w.sectionDir,
				name:       name,
				content:    renderTTCN3File(blk.Text, attrs),
			})
		case *markdown.Quote:
			if err := w.walk(blk.Blocks); err != nil {
				return err
			}
		case *markdown.List:
			for _, it := range blk.Items {
				item, ok := it.(*markdown.Item)
				if !ok {
					continue
				}
				if err := w.walk(item.Blocks); err != nil {
					return err
				}
			}
		case *markdown.Item:
			if err := w.walk(blk.Blocks); err != nil {
				return err
			}
		case *markdown.Document:
			if err := w.walk(blk.Blocks); err != nil {
				return err
			}
		case *markdown.Empty, *markdown.HTMLBlock, *markdown.Paragraph, *markdown.Text, *markdown.ThematicBreak:
		default:
			return fmt.Errorf("unsupported markdown block type %T", blk)
		}
	}
	return nil
}

func parseFenceInfo(info string) (lang, attrs string) {
	info = strings.TrimSpace(info)
	if info == "" {
		return "", ""
	}
	i := 0
	for i < len(info) {
		r, size := utf8.DecodeRuneInString(info[i:])
		if unicode.IsSpace(r) {
			lang = strings.ToLower(strings.TrimSpace(info[:i]))
			attrs = strings.TrimLeftFunc(info[i:], unicode.IsSpace)
			return lang, attrs
		}
		i += size
	}
	return strings.ToLower(info), ""
}

func renderTTCN3File(lines []string, attrs string) string {
	var b strings.Builder
	if attrs != "" {
		b.WriteString("/*\n")
		b.WriteString("code-fence-info: ")
		b.WriteString(attrs)
		b.WriteString("\n")
		if m := exampleFenceAttr.FindStringSubmatch(attrs); m != nil {
			b.WriteString("example-title: ")
			b.WriteString(m[1])
			b.WriteString("\n")
		}
		b.WriteString("*/\n\n")
	}
	if len(lines) > 0 {
		b.WriteString(strings.Join(lines, "\n"))
		b.WriteString("\n")
	}
	return b.String()
}

func inlinesText(inls markdown.Inlines) string {
	var b strings.Builder
	for _, inl := range inls {
		inlineText(&b, inl)
	}
	return strings.TrimSpace(b.String())
}

func inlineText(b *strings.Builder, inl markdown.Inline) {
	switch x := inl.(type) {
	case markdown.Inlines:
		for _, e := range x {
			inlineText(b, e)
		}
	case *markdown.Plain:
		b.WriteString(x.Text)
	case *markdown.Escaped:
		b.WriteString(x.Text)
	case *markdown.Code:
		b.WriteString(x.Text)
	case *markdown.Strong:
		for _, e := range x.Inner {
			inlineText(b, e)
		}
	case *markdown.Emph:
		for _, e := range x.Inner {
			inlineText(b, e)
		}
	case *markdown.Del:
		for _, e := range x.Inner {
			inlineText(b, e)
		}
	case *markdown.Link:
		for _, e := range x.Inner {
			inlineText(b, e)
		}
	case *markdown.Image:
		for _, e := range x.Inner {
			inlineText(b, e)
		}
	case *markdown.AutoLink:
		b.WriteString(x.Text)
	case *markdown.Emoji:
		b.WriteString(x.Text)
	case *markdown.SoftBreak, *markdown.HardBreak:
		b.WriteByte(' ')
	case *markdown.HTMLTag, *markdown.Task:
	default:
	}
}

func slugSection(s string) string {
	s = strings.ReplaceAll(s, " ", "_")
	s = strings.ToLower(s)
	s = sectionSlugSanitizer.ReplaceAllString(s, "_")
	s = strings.Trim(s, "_")
	for strings.Contains(s, "__") {
		s = strings.ReplaceAll(s, "__", "_")
	}
	if s == "" {
		return "section"
	}
	return s
}
+145 −0
Original line number Diff line number Diff line
package main

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestExtractTTCN3Files_Basic(t *testing.T) {
	src := strings.Join([]string{
		"### 16.7.2 `for loop`",
		"```ttcn3",
		"module M {}",
		"```",
		"```ttcn3 example = \"Title\"",
		"testcase tc() {}",
		"```",
	}, "\n")

	files, err := extractTTCN3Files(src)
	if err != nil {
		t.Fatalf("extractTTCN3Files failed: %v", err)
	}
	if got, want := len(files), 2; got != want {
		t.Fatalf("unexpected file count: got %d want %d", got, want)
	}

	if files[0].sectionDir != "16.7.2_for_loop" {
		t.Fatalf("unexpected sectionDir for file 0: %q", files[0].sectionDir)
	}
	if files[0].name != "ttcn3-001.ttcn3" {
		t.Fatalf("unexpected name for file 0: %q", files[0].name)
	}
	if files[0].content != "module M {}\n" {
		t.Fatalf("unexpected content for file 0: %q", files[0].content)
	}

	if files[1].name != "example_001.ttcn3" {
		t.Fatalf("unexpected name for file 1: %q", files[1].name)
	}
	if !strings.Contains(files[1].content, "code-fence-info: example = \"Title\"") {
		t.Fatalf("missing code fence info header: %q", files[1].content)
	}
	if !strings.Contains(files[1].content, "example-title: Title") {
		t.Fatalf("missing example title header: %q", files[1].content)
	}
	if !strings.HasSuffix(files[1].content, "testcase tc() {}\n") {
		t.Fatalf("missing TTCN-3 body in output: %q", files[1].content)
	}
}

func TestExtractTTCN3Files_AllHeadingLevelsAreSectionSources(t *testing.T) {
	src := strings.Join([]string{
		"## Overview",
		"```ttcn3",
		"module A {}",
		"```",
		"#### Deep Part",
		"```ttcn3",
		"module B {}",
		"```",
	}, "\n")

	files, err := extractTTCN3Files(src)
	if err != nil {
		t.Fatalf("extractTTCN3Files failed: %v", err)
	}
	if got, want := len(files), 2; got != want {
		t.Fatalf("unexpected file count: got %d want %d", got, want)
	}
	if files[0].sectionDir != "overview" {
		t.Fatalf("unexpected sectionDir for first file: %q", files[0].sectionDir)
	}
	if files[1].sectionDir != "deep_part" {
		t.Fatalf("unexpected sectionDir for second file: %q", files[1].sectionDir)
	}
}

func TestExtractTTCN3Files_ExampleCounterResetsLikeDocx(t *testing.T) {
	src := strings.Join([]string{
		"### A",
		"```ttcn3 example = \"One\"",
		"testcase one() {}",
		"```",
		"```ttcn3 example = \"Two\"",
		"testcase two() {}",
		"```",
		"#### Nested",
		"```ttcn3 example = \"Three\"",
		"testcase three() {}",
		"```",
	}, "\n")

	files, err := extractTTCN3Files(src)
	if err != nil {
		t.Fatalf("extractTTCN3Files failed: %v", err)
	}
	if got, want := len(files), 3; got != want {
		t.Fatalf("unexpected file count: got %d want %d", got, want)
	}

	if files[0].name != "example_001.ttcn3" || files[1].name != "example_002.ttcn3" || files[2].name != "example_001.ttcn3" {
		t.Fatalf("unexpected example naming/reset behavior: %q, %q, %q", files[0].name, files[1].name, files[2].name)
	}
}

func TestExtractTTCN3Files_MissingSectionHeading(t *testing.T) {
	src := strings.Join([]string{
		"```ttcn3",
		"module M {}",
		"```",
	}, "\n")

	_, err := extractTTCN3Files(src)
	if err == nil {
		t.Fatalf("expected missing section error")
	}
	if !strings.Contains(err.Error(), "no preceding heading") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestWriteExtractedTTCN3Files(t *testing.T) {
	dir := t.TempDir()
	files := []extractedTTCN3File{
		{
			sectionDir: "16.7.2_for_loop",
			name:       "ttcn3-001.ttcn3",
			content:    "module M {}\n",
		},
	}

	if err := writeExtractedTTCN3Files(dir, files); err != nil {
		t.Fatalf("writeExtractedTTCN3Files failed: %v", err)
	}
	out := filepath.Join(dir, "16.7.2_for_loop", "ttcn3-001.ttcn3")
	b, err := os.ReadFile(out)
	if err != nil {
		t.Fatalf("read output failed: %v", err)
	}
	if string(b) != "module M {}\n" {
		t.Fatalf("unexpected output content: %q", string(b))
	}
}

tools/etsi/generate.go

0 → 100644
+86 −0
Original line number Diff line number Diff line
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"etsi/internal/docx"
	htmlgen "etsi/internal/html"

	"github.com/spf13/cobra"
)

func init() {
	generateCmd := &cobra.Command{
		Use:   "generate",
		Short: "Generate output from Markdown sources",
	}

	var output, template string
	docxCmd := &cobra.Command{
		Use:   "docx <files...>",
		Short: "Generate a Word document from Markdown sources",
		Args:  cobra.MinimumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			if output == "" {
				return fmt.Errorf("docx output requires -o/--output")
			}
			tmpl, err := filepath.Abs(template)
			if err != nil {
				return err
			}
			out, err := filepath.Abs(output)
			if err != nil {
				return err
			}
			paths := make([]string, len(args))
			for i, a := range args {
				p, err := filepath.Abs(a)
				if err != nil {
					return err
				}
				paths[i] = p
			}
			return docx.WriteFromMarkdown(out, tmpl, paths)
		},
	}
	docxCmd.Flags().StringVarP(&output, "output", "o", "", "output file path (required)")
	docxCmd.Flags().StringVar(&template, "template", filepath.Join("templates", "section.docx"), "Word template")

	var htmlOutput string
	htmlCmd := &cobra.Command{
		Use:   "html <files...>",
		Short: "Generate an HTML document from Markdown sources",
		Args:  cobra.MinimumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			paths := make([]string, len(args))
			for i, a := range args {
				p, err := filepath.Abs(a)
				if err != nil {
					return err
				}
				paths[i] = p
			}
			if htmlOutput == "" {
				return htmlgen.WriteFromMarkdown(os.Stdout, paths)
			}
			out, err := filepath.Abs(htmlOutput)
			if err != nil {
				return err
			}
			f, err := os.Create(out)
			if err != nil {
				return err
			}
			defer f.Close()
			return htmlgen.WriteFromMarkdown(f, paths)
		},
	}
	htmlCmd.Flags().StringVarP(&htmlOutput, "output", "o", "", "output file path (default: stdout)")

	generateCmd.AddCommand(docxCmd)
	generateCmd.AddCommand(htmlCmd)
	generateCmd.AddCommand(newGenerateTTCN3Cmd())
	rootCmd.AddCommand(generateCmd)
}
Loading