Skip to content

Commit

Permalink
feat(errdocs): add documentation and code generation tool for error h…
Browse files Browse the repository at this point in the history
…andling

- Introduced a new Go script to generate documentation and error definitions in Markdown, TypeScript, and JavaScript formats.
- Updated .gitignore to include new output directories for generated files.
- Added a new documentation page detailing usage instructions for the code generation tool.
- Enhanced the VitePress configuration to link to the new documentation page.
  • Loading branch information
0xJacky committed Jan 8, 2025
1 parent df4d7b8 commit 3ba81c2
Show file tree
Hide file tree
Showing 4 changed files with 378 additions and 1 deletion.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ app.testing.ini
docs/.vitepress/cache
docs/.vitepress/dist
node_modules
coverage.out
coverage.out
err-docs-dist
err-ts-dist
err-js-dist
308 changes: 308 additions & 0 deletions cmd/errdocs/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package main

import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/spf13/cast"
"github.com/uozi-tech/cosy"
)

var (
// Matches patterns like: e = cosy.NewErrorScope("user") and captures:
// group(1): variable name (e.g. e)
// group(2): scope name (e.g. user)
reScope = regexp.MustCompile(`(\w+)\s*=\s*cosy\.NewErrorScope\s*\(\s*"([^"]+)"\s*\)`)

// Updated regex templates to support negative error codes
// Example: -1, -100, etc.
newTemplate = `%s\s*\.\s*New\s*\(\s*(-?[0-9]+)\s*,\s*"([^"]*)"\s*\)`
newWithParamsTemplate = `%s\s*\.\s*NewWithParams\s*\(\s*(-?[0-9]+)\s*,\s*"([^"]*)"\s*,`
)

// parseResult stores mapping of scopeName -> []ErrorInfo for a file
type parseResult map[string][]cosy.Error

func main() {
if len(os.Args) < 3 {
log.Println("Usage: go run cmd/errdocs/generate.go <project folder path> <type: md|ts|js> <output dir> [wrapper] [trailing_comma]")
log.Println("Example: go run cmd/errdocs/generate.go ./project ts ./dist $gettext true")
return
}

rootPath := os.Args[1]
docType := strings.ToLower(strings.TrimSpace(os.Args[2]))

// Validate doc type
switch docType {
case "md", "ts", "js":
// valid type
default:
log.Fatalf("Invalid type: %s. Must be one of: md, ts, js", docType)
}

// Output directory is required
if len(os.Args) < 4 {
log.Fatalf("Output directory is required")
}
outDir := os.Args[3]

// Set wrapper function and trailing comma
wrapper := "$gettext"
trailingComma := true // default to true
if len(os.Args) > 4 {
wrapper = os.Args[4]
}
if len(os.Args) > 5 {
trailingComma = strings.ToLower(os.Args[5]) != "false"
}

globalScopeMap := make(map[string][]cosy.Error)

// find all .go files
err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if filepath.Ext(path) == ".go" {
res, parseErr := parseGoFile(path)
if parseErr != nil {
log.Printf("[Error] Parse file %s error: %v\n", path, parseErr)
}
for scopeName, errArr := range res {
globalScopeMap[scopeName] = append(globalScopeMap[scopeName], errArr...)
}
}
return nil
})
if err != nil {
log.Fatalf("[Error] Walk folder error: %v\n", err)
}

// If no scope information is found, prompt and exit
if len(globalScopeMap) == 0 {
log.Println("No cosy.NewErrorScope(...) definitions or related error information found.")
return
}

// Create output directory
if err := os.MkdirAll(outDir, 0755); err != nil {
log.Fatalf("[Error] Create output directory %s failed: %v\n", outDir, err)
}

// Generate documents for each scope
for scope, errInfos := range globalScopeMap {
var outFile string
var writeErr error

switch docType {
case "md":
outFile = filepath.Join(outDir, fmt.Sprintf("%s.md", strings.ToLower(strings.ReplaceAll(scope, " ", "_"))))
writeErr = generateMarkdown(scope, errInfos, outFile)
case "ts":
outFile = filepath.Join(outDir, fmt.Sprintf("%s.ts", strings.ToLower(strings.ReplaceAll(scope, " ", "_"))))
writeErr = generateTypeScript(errInfos, outFile, wrapper, trailingComma)
case "js":
outFile = filepath.Join(outDir, fmt.Sprintf("%s.js", strings.ToLower(strings.ReplaceAll(scope, " ", "_"))))
writeErr = generateJavaScript(errInfos, outFile, wrapper, trailingComma)
}

if writeErr != nil {
log.Printf("[Error] Write to %s error: %v\n", outFile, writeErr)
} else {
log.Printf("[Generated] %s %s\n", docType, outFile)
}
}
}

// parseGoFile parses a single .go file and returns a mapping of scopeName -> []ErrorInfo
func parseGoFile(filePath string) (parseResult, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()

scopeVarMap := make(map[string]string) // varName -> scopeName
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
matches := reScope.FindStringSubmatch(line)
if len(matches) == 3 {
varName := matches[1]
scopeName := matches[2]
scopeVarMap[varName] = scopeName
}
}

if len(scopeVarMap) == 0 {
return parseResult{}, nil
}
if _, err := file.Seek(0, 0); err != nil {
return nil, err
}

// scopeName -> error array
res := make(parseResult)
for _, v := range scopeVarMap {
res[v] = []cosy.Error{}
}

scanner = bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()

for varName, scopeName := range scopeVarMap {
reNew := regexp.MustCompile(fmt.Sprintf(newTemplate, regexp.QuoteMeta(varName)))
reNewWithParams := regexp.MustCompile(fmt.Sprintf(newWithParamsTemplate, regexp.QuoteMeta(varName)))

// match .New(...)
matchesNew := reNew.FindAllStringSubmatch(line, -1)
for _, m := range matchesNew {
if len(m) == 3 {
codeStr := m[1]
msg := m[2]
res[scopeName] = append(res[scopeName], cosy.Error{Code: cast.ToInt32(codeStr), Message: msg})
}
}

// match .NewWithParams(...)
matchesNWP := reNewWithParams.FindAllStringSubmatch(line, -1)
for _, m := range matchesNWP {
if len(m) == 3 {
codeStr := m[1]
msg := m[2]
res[scopeName] = append(res[scopeName], cosy.Error{Code: cast.ToInt32(codeStr), Message: msg})
}
}
}
}

return res, scanner.Err()
}

// capitalizeFirst capitalizes the first letter of a string
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}

// generateMarkdown generates Markdown error code documentation for a single scopeName
func generateMarkdown(scopeName string, errInfos []cosy.Error, outPath string) error {
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()

// Write scope title
title := fmt.Sprintf("# %s\n\n", scopeName)
_, _ = f.WriteString(title)

// Write table header
_, _ = f.WriteString("| Error Code | Error Message |\n")
_, _ = f.WriteString("| --- | --- |\n")

// Write each error message
for _, e := range errInfos {
line := fmt.Sprintf("| %d | %s |\n", e.Code, capitalizeFirst(e.Message))
_, _ = f.WriteString(line)
}

return nil
}

// escapeString escapes special characters in a string for JavaScript/TypeScript
func escapeString(s string) string {
s = strings.ReplaceAll(s, "'", "\\'")
s = strings.ReplaceAll(s, "\"", "\\\"")
return s
}

// generateTypeScript generates TypeScript error code documentation
func generateTypeScript(errInfos []cosy.Error, outPath string, wrapper string, trailingComma bool) error {
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()

// Write export default opening
_, _ = f.WriteString("export default {\n")

// Write each error message
for i, e := range errInfos {
var codeStr string
if e.Code < 0 {
codeStr = fmt.Sprintf("'%d'", e.Code)
} else {
codeStr = fmt.Sprintf("%d", e.Code)
}

line := fmt.Sprintf(" %s: () => %s('%s')",
codeStr,
wrapper,
escapeString(capitalizeFirst(e.Message)))

// Add comma if it's not the last item or if trailingComma is true
if i < len(errInfos)-1 || trailingComma {
line += ","
}
line += "\n"
_, _ = f.WriteString(line)
}

// Write closing brace
_, _ = f.WriteString("}\n")

return nil
}

// generateJavaScript generates JavaScript error code documentation
func generateJavaScript(errInfos []cosy.Error, outPath string, wrapper string, trailingComma bool) error {
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()

// Write export default opening
_, _ = f.WriteString("module.exports = {\n")

// Write each error message
for i, e := range errInfos {
var codeStr string
if e.Code < 0 {
codeStr = fmt.Sprintf("'%d'", e.Code)
} else {
codeStr = fmt.Sprintf("%d", e.Code)
}

line := fmt.Sprintf(" %s: () => %s('%s')",
codeStr,
wrapper,
escapeString(capitalizeFirst(e.Message)))

// Add comma if it's not the last item or if trailingComma is true
if i < len(errInfos)-1 || trailingComma {
line += ","
}
line += "\n"
_, _ = f.WriteString(line)
}

// Write closing brace
_, _ = f.WriteString("}\n")

return nil
}
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default defineConfig(({mode}) => {
text: '错误处理',
items: [
{text: '接口参考', link: '/error-handler'},
{text: '文档和代码生成', link: '/error-handler/docs-code-gen'},
]
},
{
Expand Down
65 changes: 65 additions & 0 deletions docs/error-handler/docs-code-gen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 文档和代码生成

## 使用方法

```bash
go run cmd/errdocs/generate.go <project folder path> <type: md|ts|js> <output dir> [wrapper] [trailing_comma]
```

| 参数 | 说明 | 是否必填 | 默认值 |
| --- | --- | --- | --- |
| project folder path | 项目根目录路径 || - |
| type | 生成文件类型:`md`/`ts`/`js` || - |
| output dir | 输出目录 || - |
| wrapper | 错误信息包装函数 || `$gettext` |
| trailing_comma | 是否在最后一项添加逗号 || `true` |

示例:
```bash
# 生成 Markdown 文档
go run cmd/errdocs/generate.go ./project md ./docs

# 生成 TypeScript 错误定义,使用自定义 wrapper
go run cmd/errdocs/generate.go ./project ts ./src/errors t

# 生成 JavaScript 错误定义,不使用末尾逗号
go run cmd/errdocs/generate.go ./project js ./dist $gettext false
```

## 生成的文件示例

### Markdown 文档
```markdown
# auth

| Error Code | Error Message |
| --- | --- |
| 4031 | Token is empty |
| 4032 | Token convert to claims failed |
| -4033 | JWT expired |
```

### TypeScript 错误定义
```ts
export default {
4031: () => $gettext('Token is empty'),
4032: () => $gettext('Token convert to claims failed'),
'-4033': () => $gettext('JWT expired'),
}
```

### JavaScript 错误定义
```js
module.exports = {
4031: () => $gettext('Token is empty'),
4032: () => $gettext('Token convert to claims failed'),
'-4033': () => $gettext('JWT expired'),
}
```

注意:
1. 错误信息的首字母会自动转换为大写
2. 负数错误码在 TypeScript 和 JavaScript 中会使用字符串形式
3. 包含引号的错误信息会自动进行转义处理
4. 默认会在最后一项添加逗号,可以通过参数禁用

0 comments on commit 3ba81c2

Please sign in to comment.