Skip to content

Instantly share code, notes, and snippets.

@kylege
Last active April 3, 2026 07:12
Show Gist options
  • Select an option

  • Save kylege/e4ddaa1c38818083c7244b229adde9cc to your computer and use it in GitHub Desktop.

Select an option

Save kylege/e4ddaa1c38818083c7244b229adde9cc to your computer and use it in GitHub Desktop.
TML: Testing Markup Language

tml 标记语言

tml 全称 Testing Markup Language,是用于存储单元测试中变量的文本文件。格式简单

结构体定义

目前仅支持以下几种字段数据类型

  • int
  • string
  • []string
  • map[string]string

使用 tml 来标记 struct 中的字段。如

type tmlData struct {
	Sql  string            `tml:"sql"`
	Deps string            `tml:"deps"`
	Meta map[string]string `tml:"meta"`
}

按上面定义,遇到 sql 变量,会按字符串提取。meta 变量按字典规则提取。

解析示例

r, err := os.Open(path)
if err != nil {
    t.Fatalf("open %s failed, err: %s", path, err)
}
defer r.Close()

d := tml.NewDecoder(r)
var data tmlData
if err = d.Decode(&data); err != nil {
    t.Fatalf("parse tml file %s failed, err: %s", path, err)
}

tml 文件语法

变量赋值

行开始,连接两个 @ ,再接着变量名,@@ 前面不能有空白字符

例如

@@ sql
 select from
    table aaa

表示下面行所有字符串,赋值给 sql 变量

字符串变量会去掉前后空白符。

注释

行开始,连续两个 # 表示注释行。# 前不能有空白字符。

数组变量

只支持字符串数组

数组使用逗号隔开,不考虑数组值当中含有逗号的情况

## 数组示例
@@ arr1
field1, field2, 333

字典变量

只支持 map[string]string 类型。

格式为字典 key + 冒号 + value。

示例

## 字典示例
@@ map1
user: name
pass: word

tml 文件示例

对于定义结构体

struct {
		Sql   string            `tml:"sql"`
		Users []string          `tml:"users"`
		Ages  map[string]string `tml:"ages"`
		Count int               `tml:"count"`
	}

相应的 tml 文件内容如下

// 文件开始可以写一些文本,会被忽略

@@ sql
select * 
  from table a 
  union join b 

## 这是一行注释,注释开头必须是 ## ,# 前不能有空格

@@ users
## 中间还可以有注释
kyle, alice, john

@@ ages
kyle: 22
alice: 23
## 中间还可以有注释
john: 29

@@ count
## comment
    7899
package tml
import (
"bufio"
"bytes"
"errors"
"io"
"reflect"
"strconv"
"strings"
)
var (
errVariableLineFound = errors.New("variable line found")
errCommentLineFound = errors.New("comment line found")
)
type Decoder struct {
r *bufio.Reader
}
func (d *Decoder) readBytes(delim byte) (line []byte, err error) {
line, err = d.r.ReadBytes(delim)
return
}
// skipBytes 跳过所有内容直到读到 delim 字符
func (d *Decoder) skipBytes(delim byte) (err error) {
var b byte
for {
if b, err = d.readByte(); err != nil {
return
}
if b == delim {
return nil
}
}
}
// skipWhitespace 跳过所有空白字符
func (d *Decoder) skipWhitespace() {
var b byte
var err error
for {
if b, err = d.peekByte(); err != nil {
break
}
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
_, _ = d.readByte()
continue
}
break
}
}
func (d *Decoder) readByte() (b byte, err error) {
b, err = d.r.ReadByte()
return
}
func (d *Decoder) peekByte() (b byte, err error) {
ch, err := d.r.Peek(1)
if err != nil {
return
}
b = ch[0]
return
}
func (d *Decoder) peekBytes(n int) (b []byte, err error) {
b, err = d.r.Peek(n)
return
}
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{r: bufio.NewReader(r)}
}
func (d *Decoder) Decode(val interface{}) error {
rv := reflect.ValueOf(val)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return errors.New("unwritable type passed into decode")
}
return d.decodeInto(rv)
}
func DecodeString(in string, val interface{}) error {
buf := strings.NewReader(in)
d := NewDecoder(buf)
return d.Decode(val)
}
func DecodeBytes(b []byte, val interface{}) error {
r := bytes.NewReader(b)
d := NewDecoder(r)
return d.Decode(val)
}
// readVariable 一直读取到变量声明语句
//
// 例如 @@ variable_name
func (d *Decoder) readVariable() (string, error) {
var err error
var b byte
var line []byte
for {
if err = d.skipBytes(variableDelimiter); err != nil && !errors.Is(err, io.EOF) {
return "", err
}
if b, err = d.readByte(); err != nil && !errors.Is(err, io.EOF) {
return "", err
}
if errors.Is(err, io.EOF) {
return "", nil
}
if b != variableDelimiter {
continue
}
if line, err = d.readBytes('\n'); err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return string(bytes.TrimSpace(line)), nil
}
}
func (d *Decoder) readLine() ([]byte, error) {
peeks, err := d.peekBytes(2)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if len(peeks) == 2 && peeks[0] == variableDelimiter && peeks[1] == variableDelimiter {
return nil, errVariableLineFound
}
line, err := d.readBytes('\n')
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if len(line) >= 2 && line[0] == commentDelimiter && line[1] == commentDelimiter {
return nil, errCommentLineFound
}
return line, err
}
// readString 一直读取到下一个变量声明语句
func (d *Decoder) readString() (string, error) {
var err error
var b bytes.Buffer
var line []byte
for {
line, err = d.readLine()
if errors.Is(err, errVariableLineFound) {
break
}
if errors.Is(err, errCommentLineFound) {
continue
}
if _, e := b.Write(line); e != nil {
return "", e
}
if errors.Is(err, io.EOF) {
break
}
}
return strings.TrimSpace(b.String()), nil
}
// readInt 读取整数
func (d *Decoder) readInt() (int, error) {
var err error
var line []byte
var result int
for {
d.skipWhitespace()
line, err = d.readLine()
if errors.Is(err, errVariableLineFound) {
break
}
if errors.Is(err, errCommentLineFound) {
continue
}
line = bytes.TrimSpace(line)
if result, err = strconv.Atoi(string(line)); err != nil {
return 0, err
}
break
}
return result, nil
}
// readArray 读取数组
func (d *Decoder) readArray() ([]string, error) {
var err error
var line []byte
result := make([]string, 0)
for {
d.skipWhitespace()
line, err = d.readLine()
if errors.Is(err, errVariableLineFound) {
break
}
if errors.Is(err, errCommentLineFound) {
continue
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
break
}
arr := bytes.Split(line, []byte{arrayDelimiter})
result = make([]string, len(arr))
for i := range arr {
result[i] = string(bytes.TrimSpace(arr[i]))
}
break
}
return result, nil
}
// readDict 读取字典
func (d *Decoder) readDict() (map[string]string, error) {
var err error
var peeks, line []byte
result := make(map[string]string)
for {
d.skipWhitespace()
if peeks, err = d.peekBytes(2); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
if len(peeks) == 2 && peeks[0] == variableDelimiter && peeks[1] == variableDelimiter {
break
}
if line, err = d.readBytes('\n'); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
// 跳过注释行
if len(peeks) == 2 && peeks[0] == commentDelimiter && peeks[1] == commentDelimiter {
continue
}
idx := bytes.IndexByte(line, dictDelimiter)
if idx > 0 {
strk := string(bytes.TrimSpace(line[:idx]))
strv := string(bytes.TrimSpace(line[idx+1:]))
result[strk] = strv
}
if errors.Is(err, io.EOF) {
break
}
}
return result, nil
}
func (d *Decoder) decodeField(field string, v reflect.Value) error {
t := v.Elem().Type()
var value reflect.Value
for i := 0; i < v.Elem().NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" {
continue
}
name, _ := parseTag(f.Tag.Get("tml"))
if name == "" {
name = f.Name
}
if !isValidTag(name) || name != field {
continue
}
value = v.Elem().FieldByIndex(f.Index)
}
if value.Kind() == reflect.Invalid {
return errors.New("field not found: " + field)
}
switch value.Kind() {
case reflect.String:
s, err := d.readString()
if err != nil {
return err
}
value.SetString(s)
case reflect.Int:
i, err := d.readInt()
if err != nil {
return err
}
value.SetInt(int64(i))
case reflect.Slice:
if value.Type().Elem().Kind() != reflect.String {
return errors.New("only support []string for slice type")
}
arr, err := d.readArray()
if err != nil {
return err
}
value.Set(reflect.ValueOf(arr))
case reflect.Map:
if value.Type().Key().Kind() != reflect.String || value.Type().Elem().Kind() != reflect.String {
return errors.New("only support map[string]string for map type")
}
dict, err := d.readDict()
if err != nil {
return err
}
value.Set(reflect.ValueOf(dict))
default:
return errors.New("unsupported field type: " + value.Kind().String())
}
return nil
}
func (d *Decoder) decodeInto(v reflect.Value) error {
var (
fieldName string
err error
)
for {
if fieldName, err = d.readVariable(); err != nil {
return err
}
if fieldName == "" {
break
}
if err = d.decodeField(fieldName, v); err != nil {
return err
}
if _, err = d.peekByte(); errors.Is(err, io.EOF) {
break
}
}
return nil
}
package tml
import (
"strings"
"unicode"
)
// tagOptions is the string following a comma in a struct field's "bencode"
// tag, or the empty string. It does not include the leading comma.
type tagOptions string
// parseTag splits a struct field's tag into its name and
// comma-separated options.
func parseTag(tag string) (string, tagOptions) {
if idx := strings.Index(tag, ","); idx != -1 {
return tag[:idx], tagOptions(tag[idx+1:])
}
return tag, tagOptions("")
}
// Contains returns whether checks that a comma-separated list of options
// contains a particular substr flag. substr must be surrounded by a
// string boundary or commas.
func (options tagOptions) Contains(optionName string) bool {
s := string(options)
for s != "" {
var next string
i := strings.Index(s, ",")
if i >= 0 {
s, next = s[:i], s[i+1:]
}
if s == optionName {
return true
}
s = next
}
return false
}
func isValidTag(key string) bool {
if key == "" {
return false
}
for _, c := range key {
if c != ' ' && c != '$' && c != '-' && c != '_' && c != '.' && !unicode.IsLetter(c) && !unicode.IsDigit(c) {
return false
}
}
return true
}
package tml
const (
// variableDelimeter 变量声明分隔符
variableDelimiter = '@'
// commentDelimeter 注释分隔符
commentDelimiter = '#'
// arrayDelimeter 数组分隔符
arrayDelimiter = ','
// dictDelimeter 字典分隔符
dictDelimiter = ':'
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment