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

Add t3s format to rewrite TTCN-3 code blocks in place.

Fences are parsed and printed with github.com/nokia/ntt, and only those body lines are replaced.

Co-authored-by: default avatarCursor <cursoragent@cursor.com>
parent fb4154b3
Loading
Loading
Loading
Loading
+32 −12
Changes for tools/HANDOVER.md: 32 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -50,7 +50,8 @@ flowchart LR
| `internal/doc/docx` | `Read`, `Write` | |
| `internal/doc/html` | | `Read`, `Write` |
| `internal/extract` | `TTCN3` | `Grammar`, keywords, modifiers |
| `cmd/t3s` | `extract examples`, `import`, `export` | |
| `internal/ttcn3` | format code blocks | |
| `cmd/t3s` | `extract examples`, `import`, `export`, `format` | |
| `internal/lint` | | yes |

Package names avoid the word "format", because the README uses it for
@@ -250,18 +251,13 @@ These will surface once the slice runs on all of `../src`:

- **Command line.** [`cmd/t3s`](cmd/t3s) implements
  `t3s extract examples -o dir [path...]`,
  `t3s import -o dir [-f] file.docx...`, and
  `t3s export -o file.docx [path...]`.
  `t3s import -o dir [-f] file.docx...`,
  `t3s export -o file.docx [path...]`, and
  `t3s format [path...]`.
  `extract examples` writes `extract.TTCN3` results into the directory.
  Further commands wait on formatting, grammar, and lint.
- **TTCN-3 parse and format** use `github.com/nokia/ntt`:
  - Parse `Code.Lines` with `ttcn3/syntax.Parse`, which accepts declaration
    and statement fragments.
  - Format with `ttcn3/format.Fprint`, which refuses input with syntax errors.
  - Format in place by replacing lines `Pos.Line` to `EndLine` in the
    Markdown source. Never rewrite the whole file through the model.
  - Report errors at `Code.Pos.Line` plus the line inside the fence.
  - Extracted files stay verbatim.
  Grammar and lint are still open.
- **TTCN-3 parse and format** are implemented in [`internal/ttcn3`](internal/ttcn3).
  See the format slice below.
- **Grammar** uses `github.com/5nord/ebnf`:
  - `extract.Grammar` joins `ebnf` code in document order and keeps a map
    from every grammar line back to its Markdown `Pos`.
@@ -274,6 +270,30 @@ These will surface once the slice runs on all of `../src`:
  export slice below. [`../assets`](../assets) holds screenshots only.
- **DOCX import** is implemented. See the slice below.

## Format slice

`t3s format [path...]` rewrites TTCN-3 fences in the Markdown sources.
`ttcn3.Format` parses each block with `syntax.Parse` and prints it with
`format.Fprint` from `github.com/nokia/ntt`. The canonical printer uses tabs
and keeps the author's line breaks; it only repairs indentation and
spacing. `Fprint` returns the parse error when the block does not parse, so
that block is left unchanged.

The replacement is the line range `Pos.Line` through `EndLine` in the
original file. Fences, prose, and other languages are copied as bytes.
A fence indented by one to three spaces keeps that indent on the formatted
body, because the reader strips it. Blocks are edited from the bottom of
the file upward, so a block that grows or shrinks does not move the lines
of a block above it.

Syntax errors name the Markdown file. The line is the block's first body
line plus the 0-based line inside the fence (`Pos.Line + line - 1`, where
`line` is the 1-based line from the parser). Other blocks in the file are
still formatted, and the file is written. The command exits 1 when any
block failed. Clause numbers may collide: format never merges files.
Extracted examples stay a verbatim copy of whatever is in the fence after
formatting; extract itself does not format.

## DOCX import slice

`docx.Read` appends the sections of one DOCX file to a `doc.Document`.
+12 −3
Changes for tools/README.md: 12 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -14,7 +14,8 @@ The command is `t3s` (`cmd/t3s`).
t3s extract examples -o dir [path...]
t3s import -o dir [-f] file.docx...
t3s export -o file.docx [path...]
t3s help [extract|import|export]
t3s format [path...]
t3s help [extract|import|export|format]
t3s version
```

@@ -22,17 +23,25 @@ t3s version
per TTCN-3 code block. With no paths it reads `./src`. `import` writes one
canonical Markdown file per top-level section of each DOCX file. It refuses
to overwrite an existing file unless `-f` is given. `export` writes one DOCX
document. With no paths it reads `./src`.
document. With no paths it reads `./src`. `format` rewrites TTCN-3 code
blocks in the Markdown sources. With no paths it reads `./src`.

## Features

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

### Format TTCN-3 code blocks

`t3s format [path...]` rewrites each TTCN-3 fence in place. The formatter
is `github.com/nokia/ntt/ttcn3/format`. Fences and the surrounding Markdown
are left as they are. A block that does not parse is left unchanged and
reported at its line in the Markdown file. Other blocks are still formatted.

### Extract TTCN-3 code blocks

- Extract all TTCN-3 code blocks into individual files (`./examples/`)
+102 −0
Changes for tools/cmd/t3s/format.go: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"bytes"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"

	"tools/internal/doc"
	"tools/internal/doc/md"
	"tools/internal/ttcn3"
)

func cmdFormat(args []string, stdout, stderr io.Writer) int {
	fs := flag.NewFlagSet("t3s format", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	if code, ok := parseFlags(fs, args, stdout, stderr, formatHelp); !ok {
		return code
	}
	files, err := markdownFiles(fs.Args())
	if err != nil {
		return report(stderr, err, formatHelp)
	}

	type job struct {
		path string
		data []byte
	}
	var (
		jobs []job
		errs []error
	)
	for _, path := range files {
		b, err := os.ReadFile(path)
		if err != nil {
			errs = append(errs, err)
			continue
		}
		// Clause numbers need not be unique across files. Formatting
		// rewrites each file on its own.
		d := &doc.Document{}
		if err := md.Read(d, path, b); err != nil {
			errs = append(errs, err)
			continue
		}
		// A syntax error leaves that block as it is. Blocks that parse
		// are still rewritten, including when another block failed.
		out, err := ttcn3.Format(b, ttcn3.Codes(d))
		if err != nil {
			errs = append(errs, err)
		}
		if !bytes.Equal(b, out) {
			jobs = append(jobs, job{path: path, data: out})
		}
	}
	for _, j := range jobs {
		if err := replaceFile(j.path, j.data); err != nil {
			errs = append(errs, err)
			break
		}
	}
	if err := errors.Join(errs...); err != nil {
		fmt.Fprintln(stderr, err)
		return 1
	}
	return 0
}

// replaceFile writes data over path without truncating it first.
func replaceFile(path string, data []byte) error {
	fi, err := os.Stat(path)
	if err != nil {
		return err
	}
	f, err := os.CreateTemp(filepath.Dir(path), ".t3s-*")
	if err != nil {
		return err
	}
	tmp := f.Name()
	_, werr := f.Write(data)
	cerr := f.Close()
	if werr != nil {
		os.Remove(tmp)
		return werr
	}
	if cerr != nil {
		os.Remove(tmp)
		return cerr
	}
	if err := os.Chmod(tmp, fi.Mode().Perm()); err != nil {
		os.Remove(tmp)
		return err
	}
	if err := os.Rename(tmp, path); err != nil {
		os.Remove(tmp)
		return err
	}
	return nil
}
+18 −1
Changes for tools/cmd/t3s/main.go: 18 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -30,6 +30,8 @@ func run(args []string, stdout, stderr io.Writer) int {
		return cmdImport(args[1:], stdout, stderr)
	case "export":
		return cmdExport(args[1:], stdout, stderr)
	case "format":
		return cmdFormat(args[1:], stdout, stderr)
	default:
		fmt.Fprintf(stderr, "t3s: unknown command %q\n", args[0])
		fmt.Fprint(stderr, rootHelp)
@@ -47,6 +49,8 @@ func help(args []string, stdout, stderr io.Writer) int {
			topic = importHelp
		case "export":
			topic = exportHelp
		case "format":
			topic = formatHelp
		default:
			fmt.Fprintf(stderr, "t3s: unknown help topic %q\n", args[0])
			fmt.Fprint(stderr, rootHelp)
@@ -71,7 +75,8 @@ Usage:
  t3s extract examples -o dir [path...]
  t3s import -o dir [-f] file.docx...
  t3s export -o file.docx [path...]
  t3s help [extract|import|export]
  t3s format [path...]
  t3s help [extract|import|export|format]
  t3s version

`
@@ -100,6 +105,18 @@ in that directory, not in subdirectories. Files are read in clause order.

`

const formatHelp = `t3s format [path...]

Format TTCN-3 code blocks in place.

With no paths, t3s reads ./src. A directory contributes the .md files
in that directory, not in subdirectories. Files are read in clause order.

A block that does not parse is left unchanged. The error is reported at
its line in the Markdown file. Other blocks are still formatted.

`

const importHelp = `t3s import -o dir [-f] file.docx...

Write one canonical Markdown file per top-level section.
+51 −1
Changes for tools/cmd/t3s/main_test.go: 51 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -139,7 +139,7 @@ func TestUsage(t *testing.T) {
	t.Chdir(dir)

	code, stdout, stderr := runOut(t)
	if code != 2 || stdout != "" || !strings.Contains(stderr, "extract examples") {
	if code != 2 || stdout != "" || !strings.Contains(stderr, "extract examples") || !strings.Contains(stderr, "t3s format") {
		t.Fatalf("no args: code %d\nstdout %q\nstderr %q", code, stdout, stderr)
	}

@@ -167,6 +167,56 @@ func TestUsage(t *testing.T) {
	if code != 2 || !strings.Contains(stderr, "t3s: src: no such directory") {
		t.Fatalf("src: code %d\n%s", code, stderr)
	}

	code, stdout, stderr = runOut(t, "help", "format")
	if code != 0 || !strings.Contains(stdout, "in place") || stderr != "" {
		t.Fatalf("help format: code %d\nstdout %q\nstderr %q", code, stdout, stderr)
	}

	code, _, stderr = runOut(t, "format")
	if code != 2 || !strings.Contains(stderr, "t3s: src: no such directory") {
		t.Fatalf("format src: code %d\n%s", code, stderr)
	}
}

func TestFormat(t *testing.T) {
	dir := t.TempDir()
	t.Chdir(dir)
	const src = "# T\n\nKeep this.\n\n```ttcn3\nvar integer x:=1;\n```\n\n```ebnf\nA  ::=  B\n```\n"
	if err := os.WriteFile("10-t.md", []byte(src), 0o644); err != nil {
		t.Fatal(err)
	}
	const bad = "# T\n\n```ttcn3\nvar integer y := ;\n```\n"
	if err := os.WriteFile("11-bad.md", []byte(bad), 0o644); err != nil {
		t.Fatal(err)
	}
	code, _, stderr := runOut(t, "format", "10-t.md", "11-bad.md")
	if code != 1 || !strings.Contains(stderr, "11-bad.md:") {
		t.Fatalf("code %d\n%s", code, stderr)
	}
	got, err := os.ReadFile("10-t.md")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(got), "var integer x := 1;") || !strings.Contains(string(got), "A  ::=  B") || !strings.Contains(string(got), "Keep this.") {
		t.Fatalf("formatted:\n%s", got)
	}
	kept, err := os.ReadFile("11-bad.md")
	if err != nil || string(kept) != bad {
		t.Fatalf("bad file changed: %q %v", kept, err)
	}

	code, _, stderr = runOut(t, "format", "10-t.md")
	if code != 0 {
		t.Fatal(stderr)
	}
	again, err := os.ReadFile("10-t.md")
	if err != nil {
		t.Fatal(err)
	}
	if string(again) != string(got) {
		t.Fatalf("second pass\n%s", again)
	}
}

func TestExport(t *testing.T) {
Loading