diff --git a/.gitignore b/.gitignore index 596f9372..e570cc58 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__ profile.cov .idea/ +docs/ diff --git a/cmds/kcl-go/command/cmd_doc.go b/cmds/kcl-go/command/cmd_doc.go index 083a8d9b..05e6b4a6 100644 --- a/cmds/kcl-go/command/cmd_doc.go +++ b/cmds/kcl-go/command/cmd_doc.go @@ -4,29 +4,107 @@ package command import ( "fmt" - "os" - "os/exec" - "github.com/urfave/cli/v2" - "kcl-lang.io/kcl-go/pkg/kclvm_runtime" + "kcl-lang.io/kcl-go/pkg/tools/doc" ) +const version = "v0.0.1" + func NewDocCmd() *cli.Command { return &cli.Command{ - Hidden: false, - SkipFlagParsing: true, - Name: "doc", - Usage: "show documentation for package or symbol", + Hidden: true, + Name: "doc", + Usage: "show documentation for package or symbol", + UsageText: `# Generate document for current package +kcl-go doc generate + +# Start a local KCL document server +kcl-go doc start`, + Subcommands: []*cli.Command{ + { + Name: "generate", + Usage: "generates documents from code and examples", + UsageText: `# Generate Markdown document for current package +kcl-go doc generate + +# Generate Html document for current package +kcl-go doc generate --format html + +# Generate Markdown document for specific package +kcl-go doc generate --file-path + +# Generate Markdown document for specific package to a +kcl-go doc generate --file-path --target `, + Flags: []cli.Flag{ + // todo: look for packages recursive + // todo: package path list + &cli.StringFlag{ + Name: "file-path", + Usage: `Relative or absolute path to the KCL package root when running kcl-doc command from + outside of the KCL package root directory. + If not specified, docs of all the KCL models under the work directory will be generated.`, + }, + &cli.BoolFlag{ + Name: "ignore-deprecated", + Usage: "do not generate documentation for deprecated schemas", + Value: false, + }, + &cli.StringFlag{ + Name: "format", + Usage: "The document format to generate. Supported values: markdown, html, openapi", + Value: string(doc.Markdown), + }, + &cli.StringFlag{ + Name: "target", + Usage: "If not specified, the current work directory will be used. A docs/ folder will be created under the target directory", + }, + }, + Action: func(context *cli.Context) error { + opts := doc.GenOpts{ + Path: context.String("file-path"), + IgnoreDeprecated: context.Bool("ignore-deprecated"), + Format: context.String("format"), + Target: context.String("target"), + } + + genContext, err := opts.ValidateComplete() + if err != nil { + fmt.Println(fmt.Errorf("generate failed: %s", err)) + } + + err = genContext.GenDoc() + if err != nil { + fmt.Println(fmt.Errorf("generate failed: %s", err)) + return err + } else { + fmt.Println(fmt.Sprintf("Generate Complete! Check generated docs in %s", genContext.Target)) + return nil + } + }, + }, + { + Name: "start", + Usage: "starts a document website locally", + Action: func(context *cli.Context) error { + fmt.Println("not implemented") + return nil + }, + }, + }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "version", + }, + }, Action: func(c *cli.Context) error { - args := append([]string{"-m", "kclvm.tools.docs"}, c.Args().Slice()...) - cmd := exec.Command(kclvm_runtime.MustGetKclvmPath(), args...) - stdoutStderr, err := cmd.CombinedOutput() - if err != nil { - fmt.Print(string(stdoutStderr)) - fmt.Println("ERR:", err) - os.Exit(1) + if c.NArg() == 0 { + _ = cli.ShowCommandHelp(c, c.Command.Name) + return nil + } + arg := c.Args().First() + if arg == "version" { + fmt.Println(version) } - fmt.Print(string(stdoutStderr)) return nil }, } diff --git a/go.mod b/go.mod index 6368ec99..5d560bf3 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/powerman/rpc-codec v1.2.2 github.com/qri-io/jsonpointer v0.1.1 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.8.4 github.com/urfave/cli/v2 v2.6.0 github.com/wk8/go-ordered-map/v2 v2.1.8 google.golang.org/grpc v1.53.0 diff --git a/go.sum b/go.sum index 80364d6b..586b1cda 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,6 @@ github.com/chai2010/protorpc v1.1.4 h1:CTtFUhzXRoeuR7FtgQ2b2vdT/KgWVpCM+sIus8zJj github.com/chai2010/protorpc v1.1.4/go.mod h1:/wO0kiyVdu7ug8dCMrA2yDr2vLfyhsLEuzLa9J2HJ+I= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= @@ -39,13 +38,8 @@ github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJ github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli/v2 v2.6.0 h1:yj2Drkflh8X/zUrkWlWlUjZYHyWN7WMmpVxyxXIUyv8= github.com/urfave/cli/v2 v2.6.0/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -67,7 +61,6 @@ google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175 google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= kcl-lang.io/kcl-artifact-go v0.5.1 h1:1OIeb1xdq72XIvd9mftBvqFJbzbEkPjGtdeiQizUFj8= diff --git a/pkg/tools/doc/doc_test.go b/pkg/tools/doc/doc_test.go new file mode 100644 index 00000000..b27d31df --- /dev/null +++ b/pkg/tools/doc/doc_test.go @@ -0,0 +1,64 @@ +package doc + +import ( + "github.com/stretchr/testify/assert" + kcl "kcl-lang.io/kcl-go" + "runtime" + "strings" + "testing" +) + +func TestDocRender(t *testing.T) { + tcases := [...]struct { + source *kcl.KclType + expect string + }{ + { + source: &kcl.KclType{ + SchemaName: "Person", + SchemaDoc: "Description of Schema Person", + Properties: map[string]*kcl.KclType{"name": { + Type: "string", + }}, + Required: []string{"name"}, + }, + expect: `## Schema Person + +Description of Schema Person + +### Attributes + +**name** *required* + +` + "`" + `string` + "`" + ` + +todo: The description of the property + +### Examples + +todo: The example section + +## Source Files + +- [Person](todo: filepath) +`, + }, + } + + context := GenContext{ + Format: Markdown, + IgnoreDeprecated: true, + } + + for _, tcase := range tcases { + content, err := context.renderContent(tcase.source) + if err != nil { + t.Errorf("render failed, err: %s", err) + } + expect := tcase.expect + if runtime.GOOS == "windows" { + expect = strings.ReplaceAll(tcase.expect, "\n", "\r\n") + } + assert.Equal(t, expect, string(content), "render content mismatch") + } +} diff --git a/pkg/tools/doc/kdoc.go b/pkg/tools/doc/kdoc.go new file mode 100644 index 00000000..12b68f78 --- /dev/null +++ b/pkg/tools/doc/kdoc.go @@ -0,0 +1,172 @@ +package doc + +import ( + "bytes" + _ "embed" + "fmt" + kcl "kcl-lang.io/kcl-go" + "os" + "path" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/schema.gotmpl +var schemaDocTmpl string + +var tmpl *template.Template + +func init() { + var err error + // todo: change to nested template files + tmpl, err = template.New("doc.md").Funcs(funcMap()).Parse(schemaDocTmpl) + if err != nil { + panic(err) + } +} + +// GenContext defines the context during the generation +type GenContext struct { + // PackagePath is the package path to the package or module to generate docs for + PackagePath string + // Format is the doc format to output + Format Format + // Target is the target directory to output the docs + Target string + // IgnoreDeprecated defines whether to generate documentation for deprecated schemas + IgnoreDeprecated bool +} + +// GenOpts is the user interface defines the doc generate options +type GenOpts struct { + // Path is the path to the directory or file to generate docs for + Path string + // Format is the doc format to output + Format string + // Target is the target directory to output the docs + Target string + // IgnoreDeprecated defines whether to generate documentation for deprecated schemas + IgnoreDeprecated bool +} + +type Format string + +const ( + Html Format = "html" + Markdown Format = "md" +) + +func (g *GenContext) render(schemas map[string]*kcl.KclType) error { + // make directory + err := os.MkdirAll(g.Target, 0755) + if err != nil { + return fmt.Errorf("failed to create docs/ directory under the target directory: %s", err) + } + for _, schema := range schemas { + // get doc file name + fileName := fmt.Sprintf("%s.md", schema.SchemaName) + // render doc content + content, err := g.renderContent(schema) + if err != nil { + return err + } + // write content to file + err = os.WriteFile(filepath.Join(g.Target, fileName), content, 0644) + if err != nil { + return fmt.Errorf("failed to write file %s in %s: %v", fileName, g.Target, err) + } + } + return nil +} + +func funcMap() template.FuncMap { + return template.FuncMap{ + "containsString": func(list []string, elem string) bool { + for _, s := range list { + if s == elem { + return true + } + } + return false + }, + } +} + +func (g *GenContext) renderContent(schema *kcl.KclType) ([]byte, error) { + var contentBuf bytes.Buffer + err := tmpl.Execute(&contentBuf, schema) + if err != nil { + return nil, fmt.Errorf("failed to render schema type %s with template", schema.SchemaName) + } + return contentBuf.Bytes(), nil +} + +func (opts *GenOpts) ValidateComplete() (*GenContext, error) { + g := &GenContext{} + // --- format --- + switch strings.ToLower(opts.Format) { + case string(Markdown): + g.Format = Markdown + break + case string(Html): + g.Format = Html + break + default: + return nil, fmt.Errorf("invalid generate format. Allow values: %s", []Format{Markdown, Html}) + } + + // --- package path --- + absPath, err := filepath.Abs(opts.Path) + if err != nil { + return nil, fmt.Errorf("invalid file path(%s) to generate document from, can not get the absolute path: %s", opts.Path, err) + } + _, err = os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("invalid file path(%s) to generate document from, path not exists: %s", opts.Path, err) + } + g.PackagePath = absPath + + // --- target --- + if opts.Target == "" { + // complete target output directory + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get default target directory: %s", err) + } + g.Target = cwd + } else { + // check if the target output directory is a valid directory path + file, err := os.Stat(opts.Target) + if err != nil { + return nil, fmt.Errorf("invalid target directory(%s) to output the doc files, path not exists: %s", opts.Target, err) + } + if !file.IsDir() { + return nil, fmt.Errorf("invalid target directory(%s) to output the doc files: not a directory", opts.Target) + } + } + g.Target = path.Join(g.Target, "docs") + if _, err := os.Stat(g.Target); err == nil { + // check and warn if the docs directory already exists + fmt.Println(fmt.Sprintf("[Warn] path %s exists, all the content will be overwritten", g.Target)) + if err := os.RemoveAll(g.Target); err != nil { + return nil, fmt.Errorf("failed to remove existing content in %s:%s", g.Target, err) + } + } + return g, nil +} + +func (g *GenContext) GenDoc() error { + typeMapping, err := kcl.GetSchemaTypeMapping(g.PackagePath, "", "") + if err != nil { + return fmt.Errorf("parse schema type from file failed: %s", err) + } + if len(typeMapping) == 0 { + return fmt.Errorf("no schema found") + } + err = g.render(typeMapping) + if err != nil { + return fmt.Errorf("render doc failed: %s", err) + } + return nil +} diff --git a/pkg/tools/doc/templates/schema.gotmpl b/pkg/tools/doc/templates/schema.gotmpl new file mode 100644 index 00000000..f383fb7f --- /dev/null +++ b/pkg/tools/doc/templates/schema.gotmpl @@ -0,0 +1,19 @@ +## Schema {{.SchemaName}} + +{{.SchemaDoc}} + +### Attributes + +{{range $name, $property := .Properties}}**{{$name}}**{{if containsString $.Required $name }} *required*{{end}} + +`{{$property.Type}}` + +todo: The description of the property + +{{end}}{{/*if eq .Example ""*/}}### Examples + +todo: The example section +{{/*end*/}} +## Source Files + +- [{{.SchemaName}}](todo: filepath)