diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 022dd85..a6a9acf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,7 +36,7 @@ jobs: run: staticcheck ./... - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: version: latest args: --timeout=5m diff --git a/.gitignore b/.gitignore index 3711d55..d07074b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ main bin/ gogen .env -dist/ \ No newline at end of file +dist/ +go-ts-types-out +.golangci.local.json diff --git a/.golangci.bck.v1.yml b/.golangci.bck.v1.yml new file mode 100644 index 0000000..4feba06 --- /dev/null +++ b/.golangci.bck.v1.yml @@ -0,0 +1,68 @@ +run: + timeout: 5m + issues-exit-code: 1 + tests: true + modules-download-mode: readonly + +output: + formats: + - format: colored-line-number + +linters-settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - github.com/luigimorel/gogen + - github.com/urfave/cli/v2 + gofmt: + simplify: true + goimports: + local-prefixes: github.com/luigimorel/gogen + misspell: + locale: US + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gofmt + - goimports + - misspell + - goconst + - gosec + - unconvert + - dupl + - gocritic + - gocyclo + - whitespace + - bodyclose + - depguard + - dogsled + + disable: + - funlen + - gochecknoglobals + - gocognit + - godot + - godox + - nestif + +issues: + exclude-rules: + - path: _test\.go + linters: + - gosec + - dupl + - linters: + - lll + source: "^//go:generate " + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.golangci.yml b/.golangci.yml index 4feba06..e6cffa6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,52 +1,25 @@ +version: "2" run: - timeout: 5m + modules-download-mode: readonly issues-exit-code: 1 tests: true - modules-download-mode: readonly - output: formats: - - format: colored-line-number - -linters-settings: - depguard: - rules: - main: - files: - - $all - allow: - - $gostd - - github.com/luigimorel/gogen - - github.com/urfave/cli/v2 - gofmt: - simplify: true - goimports: - local-prefixes: github.com/luigimorel/gogen - misspell: - locale: US - + text: + path: stdout linters: enable: - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - unused - - gofmt - - goimports - - misspell - - goconst - - gosec - - unconvert + - bodyclose + - depguard + - dogsled - dupl + - goconst - gocritic - gocyclo + - gosec + - misspell + - unconvert - whitespace - - bodyclose - - depguard - - dogsled - disable: - funlen - gochecknoglobals @@ -54,15 +27,53 @@ linters: - godot - godox - nestif - + settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - github.com/luigimorel/gogen + - github.com/urfave/cli/v2 + misspell: + locale: US + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - dupl + - gosec + path: _test\.go + - linters: + - lll + source: '^//go:generate ' + paths: + - third_party$ + - builtin$ + - examples$ issues: - exclude-rules: - - path: _test\.go - linters: - - gosec - - dupl - - linters: - - lll - source: "^//go:generate " max-issues-per-linter: 0 max-same-issues: 0 +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/luigimorel/gogen + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..62773f3 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "errors" + "fmt" + "path/filepath" + + gentstypes "github.com/luigimorel/gogen/internal/gen-ts-types" + "github.com/urfave/cli/v2" +) + +func GenerateCommand() *cli.Command { + return &cli.Command{ + Name: "generate", + Usage: "Utilities to generate code, go or ts", + ArgsUsage: "", + Description: `Utilities to generate code, go or ts`, + Subcommands: []*cli.Command{ + typeGenCommand(), + }, + } +} + +func typeGenCommand() *cli.Command { + return &cli.Command{ + Name: "types", + Usage: "Generate TypeScript types from Go code", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "Go package path, local dir/file, or package.Type (like go doc)", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output .d.ts file or directory", + Value: "./go-ts-types-out", + }, + }, + Action: func(c *cli.Context) error { + input := c.String("input") + output := c.String("output") + + if input == "" { + return errors.New("missing -input: provide a go package path, local dir/file, or package.Type") + } + + absOut, err := filepath.Abs(output) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for output: %w", err) + } + + err = gentstypes.Generate(input, absOut) + if err != nil { + return fmt.Errorf("failed to generate typescript types: %w", err) + } + + fmt.Println("Generation complete:", absOut) + return nil + }, + } +} diff --git a/cmd/frontend.go b/cmd/frontend.go index 554991c..244ea65 100644 --- a/cmd/frontend.go +++ b/cmd/frontend.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os/exec" "strings" @@ -85,7 +86,7 @@ Usage: }, Action: func(c *cli.Context) error { if c.NArg() == 0 { - return fmt.Errorf("framework type is required. Usage: gogen frontend ") + return errors.New("framework type is required. Usage: gogen frontend ") } frameworkType := c.Args().Get(0) @@ -122,14 +123,14 @@ func (fm *FrontendManager) validateSetup() error { switch fm.Runtime { case node: if !fm.commandExists("node") { - return fmt.Errorf("node.js is required but not installed. Please install Node.js from https://nodejs.org/") + return errors.New("node.js is required but not installed. Please install Node.js from https://nodejs.org/") } if !fm.commandExists("npm") { - return fmt.Errorf("npm is required but not installed. Please install npm") + return errors.New("npm is required but not installed. Please install npm") } case bun: if !fm.commandExists("bun") { - return fmt.Errorf("bun is required but not installed. Please install Bun from https://bun.sh/") + return errors.New("bun is required but not installed. Please install Bun from https://bun.sh/") } default: return fmt.Errorf("unsupported runtime: %s. Supported runtimes: node, bun", fm.Runtime) diff --git a/cmd/init.go b/cmd/init.go index 475bc2a..1ae46af 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -14,6 +14,7 @@ func App() *cli.App { NewCommand(), InstallCommand(), FrontendCommand(), + GenerateCommand(), }, } } diff --git a/cmd/install.go b/cmd/install.go index b509ed5..6f2c686 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "io" "net/http" @@ -133,7 +134,7 @@ func (i *Installer) binaryInstall() error { func (i *Installer) nixInstall() error { if !i.commandExists("nix-env") && !i.commandExists("nix") { - return fmt.Errorf("nix is not installed on this system") + return errors.New("nix is not installed on this system") } fmt.Println("Nix package not available yet, using binary installation...") @@ -142,7 +143,7 @@ func (i *Installer) nixInstall() error { func (i *Installer) brewInstall() error { if !i.commandExists("brew") { - return fmt.Errorf("homebrew is not installed on this system") + return errors.New("homebrew is not installed on this system") } fmt.Println("Homebrew formula not available yet, using binary installation...") diff --git a/cmd/new.go b/cmd/new.go index d4187a4..8372408 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -1,13 +1,14 @@ package cmd import ( + "errors" "fmt" "os" - "os/exec" - - "github.com/urfave/cli/v2" + "path/filepath" "github.com/luigimorel/gogen/internal" + "github.com/luigimorel/gogen/internal/constants" + "github.com/urfave/cli/v2" ) // Template constants @@ -121,7 +122,7 @@ This command will create a new directory, initialize a Go module, and create a n // Check if runtime was explicitly set by user runtimeExplicitlySet := c.IsSet("runtime") if runtimeExplicitlySet && template != TemplateWeb { - return fmt.Errorf("runtime flag is only applicable when template is 'web'") + return errors.New("runtime flag is only applicable when template is 'web'") } creator := NewProjectCreator(projectName, moduleName, template, router, frontend, projectDir, runtime, editor, useTypeScript, useTailwind) @@ -170,15 +171,15 @@ func (pc *ProjectCreator) execute() error { func (pc *ProjectCreator) validate() error { if pc.FrontendFramework != "" && pc.Template != TemplateWeb { - return fmt.Errorf("frontend flag is only applicable when template is 'web'") + return errors.New("frontend flag is only applicable when template is 'web'") } if pc.UseTypeScript && pc.FrontendFramework == "" { - return fmt.Errorf("TypeScript flag is only applicable when frontend is specified") + return errors.New("TypeScript flag is only applicable when frontend is specified") } if pc.UseTailwind && pc.FrontendFramework == "" { - return fmt.Errorf("tailwind flag is only applicable when frontend is specified") + return errors.New("tailwind flag is only applicable when frontend is specified") } return nil @@ -208,16 +209,29 @@ func (pc *ProjectCreator) ChangeToProjectDirectory() (string, func(), error) { func (pc *ProjectCreator) initializeGoModule() error { if pc.Template != TemplateWeb { - moduleName := pc.ModuleName - if moduleName == "" { + var moduleName string + switch { + case pc.ModuleName != "": + moduleName = pc.ModuleName + case pc.Name != "": moduleName = pc.Name + default: + moduleName = "my-go-module" } - cmd := exec.Command("go", "mod", "init", moduleName) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to initialize go module: %w", err) + + f, err := os.Create(filepath.Join(pc.DirName, "go.mod")) + switch { + case os.IsExist(err): + return errors.New("a go.mod file already exists in the project directory") + case err != nil: + return fmt.Errorf("failed to create go.mod file: %w", err) + } + defer f.Close() + _, err = f.WriteString("module " + moduleName + "\n\ngo " + constants.LatestGoVersion) + if err != nil { + return fmt.Errorf("failed to write to go.mod file: %w", err) } + } return nil } diff --git a/cmd/router.go b/cmd/router.go index 109d1d6..49d4834 100644 --- a/cmd/router.go +++ b/cmd/router.go @@ -1,10 +1,15 @@ package cmd import ( + "bytes" + "errors" "fmt" + "io" "os" "os/exec" + "time" + "github.com/luigimorel/gogen/internal/stdlibtemplate" "github.com/urfave/cli/v2" ) @@ -13,7 +18,7 @@ const ( RouterStdlib = "stdlib" RouterChi = "chi" RouterGorilla = "gorilla" - RouterHttpRouter = "httprouter" + RouterHTTPRouter = "httprouter" ) type Router struct { @@ -57,7 +62,7 @@ Usage: }, Action: func(c *cli.Context) error { if c.NArg() == 0 { - return fmt.Errorf("router type is required. Usage: gogen router ") + return errors.New("router type is required. Usage: gogen router ") } routerType := c.Args().Get(0) @@ -70,9 +75,10 @@ Usage: } func (r *Router) execute() error { - if err := r.validateProject(); err != nil { - return err - } + // A main.go is not required for a valid project, ex packages, creeate it in updatemainFile() + // if err := r.validateProject(); err != nil { + // return err + // } if err := r.installDependency(); err != nil { return fmt.Errorf("failed to install router dependency: %w", err) @@ -89,13 +95,6 @@ func (r *Router) execute() error { return nil } -func (r *Router) validateProject() error { - if _, err := os.Stat("go.mod"); err != nil { - return fmt.Errorf("no go.mod found - please run this command in a Go project directory") - } - return nil -} - func (r *Router) installDependency() error { var dependency string @@ -107,7 +106,7 @@ func (r *Router) installDependency() error { dependency = "github.com/go-chi/chi/v5" case RouterGorilla: dependency = "github.com/gorilla/mux" - case RouterHttpRouter: + case RouterHTTPRouter: dependency = "github.com/julienschmidt/httprouter" default: return fmt.Errorf("unsupported router type: %s", r.Type) @@ -127,18 +126,68 @@ func (r *Router) installDependency() error { } func (r *Router) updateMainFile() error { - mainContent, err := os.ReadFile("main.go") + var mainFile *os.File + var err error + + mainFile, err = os.Open("main.go") + switch { + case os.IsNotExist(err): + _, err := os.Create("main.go") + if err != nil { + return fmt.Errorf("failed to create main.go: %w", err) + } + case err != nil: + return fmt.Errorf("failed to check if main.go exists: %w", err) + default: + + var backupFileName = "main.go.backup" + _, err := os.Stat(backupFileName) + switch { + case os.IsNotExist(err): + // keep the name + case err != nil: + return fmt.Errorf("failed to check if backup file exists: %w", err) + default: + // If stat does not return a error, the file exist, hence create a timestamped backup file + backupFileName = "main.go.backup" + time.Now().Format("20060102150405") + + } + + backupFile, err := os.Create(backupFileName) + switch { + case err != nil: + return fmt.Errorf("failed to create backup file: %w", err) + default: + defer backupFile.Close() + _, err = io.Copy(backupFile, mainFile) + if err != nil { + return fmt.Errorf("failed to copy main.go to backup file: %w", err) + } + } + } + + defer mainFile.Close() + + mainContent := &bytes.Buffer{} + + _, err = io.Copy(mainContent, mainFile) if err != nil { return fmt.Errorf("failed to read main.go: %w", err) } - newContent := r.generateMainContent(string(mainContent)) + _, err = mainContent.WriteString(r.generateMainContent(mainContent.String())) + if err != nil { + return fmt.Errorf("failed to write updated main.go: %w", err) + } + + _ = mainFile.Truncate(0) - backupPath := "main.go.backup" - if err := os.WriteFile(backupPath, mainContent, 0600); err != nil { - fmt.Printf("Warning: failed to create backup at %s: %v\n", backupPath, err) + _, err = mainFile.Seek(0, 0) + if err != nil { + return fmt.Errorf("failed to seek to beginning of main.go: %w", err) } - if err := os.WriteFile("main.go", []byte(newContent), 0600); err != nil { + _, err = io.Copy(mainFile, mainContent) + if err != nil { return fmt.Errorf("failed to write updated main.go: %w", err) } @@ -148,13 +197,20 @@ func (r *Router) updateMainFile() error { func (r *Router) generateMainContent(existingContent string) string { switch r.Type { case RouterStdlib: + // ugly addition as the command currently only support updating a main.go file + err := stdlibtemplate.CreateRouterSetup() + if err != nil { + fmt.Printf("Warning: failed to create router setup: %v", err) + return "" // not ideal + } + return r.generateServeMuxContent() case RouterChi: return r.generateChiContent() case RouterGorilla: return r.generateGorillaContent() - case RouterHttpRouter: - return r.generateHttpRouterContent() + case RouterHTTPRouter: + return r.generateHTTPRouterContent() default: return existingContent } @@ -177,7 +233,7 @@ func (r *Router) printInstructions() { fmt.Println(" - Path variables: r.HandleFunc(\"/users/{id}\", handler)") fmt.Println(" - Query parameter matching") fmt.Println(" - Host and scheme matching") - case RouterHttpRouter: + case RouterHTTPRouter: fmt.Println("\nHttpRouter features:") fmt.Println(" - Extremely fast performance") fmt.Println(" - Path parameters: router.GET(\"/users/:id\", handler)") @@ -194,42 +250,22 @@ func (r *Router) generateServeMuxContent() string { return `package main import ( - "encoding/json" "fmt" "log" "net/http" -) -type Response struct { - Message string ` + "`json:\"message\"`" + ` - Router string ` + "`json:\"router\"`" + ` -} + "/router" +) func main() { - mux := http.NewServeMux() - - mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - response := Response{ - Message: "Hello from http.ServeMux", - Router: "http.ServeMux", - } - json.NewEncoder(w).Encode(response) + // Example of adding a handler to the router + router.Router.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Hello from the stdlib router!") }) - mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - response := Response{ - Message: "API is healthy", - Router: "http.ServeMux", - } - json.NewEncoder(w).Encode(response) - }) - - port := ":8080" - fmt.Printf("Starting API server with http.ServeMux on http://localhost%s\n", port) - log.Fatal(http.ListenAndServe(port, mux)) + addr := ":8080" + fmt.Printf("Starting server on http://localhost%s\n", addr) + log.Fatal(router.Serve(addr)) } ` } @@ -337,7 +373,7 @@ func main() { ` } -func (r *Router) generateHttpRouterContent() string { +func (r *Router) generateHTTPRouterContent() string { return `package main import ( diff --git a/go.mod b/go.mod index af9131c..8b3fadf 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,19 @@ module github.com/luigimorel/gogen -go 1.21.13 +go 1.24.1 -require github.com/urfave/cli/v2 v2.27.7 +require ( + github.com/urfave/cli/v2 v2.27.7 + golang.org/x/tools v0.36.0 + gopkg.hlmpn.dev/pkg/go-logger v1.0.0 +) require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/goccy/go-reflect v1.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.17.0 // indirect + gopkg.hlmpn.dev/pkg/xprint v0.0.7 // indirect ) diff --git a/go.sum b/go.sum index f749703..511f51e 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,22 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms= +github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.hlmpn.dev/pkg/go-logger v1.0.0 h1:8yZr6WE/XLQtD5+UNM0poz0CzdH9ybdc60r/ll7Qhec= +gopkg.hlmpn.dev/pkg/go-logger v1.0.0/go.mod h1:ORt5PSuc70gtLQN5miGBUfC+DHF9uyNIu2TrtOD8JD4= +gopkg.hlmpn.dev/pkg/xprint v0.0.7 h1:/AtE33kfwIgKZxRFoEr/AIyE1ggp7oCmhSAzmPTw6n8= +gopkg.hlmpn.dev/pkg/xprint v0.0.7/go.mod h1:ohIxnGCZ2BkDtnrNX5O3ygPHmrjIkmZT1sJnB/ia6wQ= diff --git a/golangci.local.json b/golangci.local.json new file mode 100644 index 0000000..2f933f3 --- /dev/null +++ b/golangci.local.json @@ -0,0 +1,274 @@ +{ + "formatters": { + "exclusions": { + "generated": "lax", + "paths": [ + "third_party$", + "builtin$", + "examples$", + "_test\\.go$" + ] + } + }, + "run": { + "allow-parallel-runners": true, + "concurrency": 4, + "go": "1.24.1" + }, + "version": "2", + "linters": { + "enable": [ + "bodyclose", + "errname", + "forcetypeassert", + "gochecknoinits", + "goconst", + "gocritic", + "gosec", + "iface", + "intrange", + "ireturn", + "makezero", + "mirror", + "misspell", + "mnd", + "nilnesserr", + "nilnil", + "nonamedreturns", + "nosprintfhostport", + "perfsprint", + "prealloc", + "predeclared", + "revive", + "testpackage", + "unconvert", + "wastedassign", + "wrapcheck" + ], + "exclusions": { + "generated": "lax", + "paths": [ + "third_party$", + "builtin$", + "examples$", + "_test\\.go$" + ], + "presets": [ + "comments", + "common-false-positives", + "legacy", + "std-error-handling" + ], + "rules": [ + { + "linters": [ + "errcheck", + "gosec" + ], + "path": "_test\\.go" + }, + { + "path": "(.+)\\.go$", + "text": "G114" + }, + { + "path": "(.+)\\.go$", + "text": "should omit type .* from declaration of var" + }, + { + "linters": [ "wrapcheck" ], + "source": "NewResult|NewResultWithError|NewDirectResult" + } + ] + }, + "settings": { + "errcheck": { + "exclude-functions": [ + "(github.com/gin-gonic/gin.ResponseWriter).Write", + "(net/http.ResponseWriter).Write", + "jsonres.Serve.*" + ] + }, + "gocritic": { + "enabled-checks": [ + "nilValReturn", + "returnAfterHttpError", + "sliceClear", + "unnecessaryDefer" + ] + }, + "mnd": { + "checks": [ + "argument", + "case", + "condition", + "operation", + "return", + "assign" + ], + "ignored-functions": [ + "^math\\.", + "^os\\.Open\\w*$", + "^os\\.Mkdir\\w*$", + "^os\\.Chmod\\w*$", + "^os\\.Create\\w*$", + "^syscall\\.Chmod\\w*$", + "^syscall\\.Mkdir\\w*$", + "^time\\.Sleep$", + "^time\\.Duration$", + "^jsonres\\.", + "^http\\.ServeFile$", + "^http\\.ServeContent$", + "^http\\.Redirect$", + "^make$", + "^cap$", + "^len$", + "^copy$", + "^append$", + "^strings\\.Replace\\w*$", + "^strings\\.Count\\w*$", + "^strings\\.Index\\w*$", + "^strings\\.LastIndex\\w*$", + "^bytes\\.Replace\\w*$", + "^bytes\\.Count\\w*$", + "^bytes\\.Index\\w*$", + "^bytes\\.LastIndex\\w*$", + "^unicode/utf8\\.\\w*$", + "^utf8\\.\\w*$", + "^strconv\\.Format\\w*$", + "^strconv\\.Parse\\w*$", + "^strconv\\.Append\\w*$", + "^strconv\\.Atoi$", + "^strconv\\.Itoa$", + "^encoding/binary\\.\\w*$", + "^crypto/\\w*$", + "^sort\\.\\w*$", + "^regexp\\.\\w*$", + "^net\\.\\w*$", + "^bufio\\.\\w*$", + "^io\\.\\w*$", + "^fmt\\.\\w*$", + "^encoding/json\\.\\w*$", + "^encoding/base64\\.\\w*$", + "^rand\\.Int\\w*$", + "^rand\\.Float\\w*$", + "^reflect\\.\\w*$", + "^runtime\\.\\w*$", + "^net/http\\.\\w*$", + "^net/url\\.\\w*$", + "^context\\.\\w*$", + "^sync\\.\\w*$", + "^database/sql\\.\\w*$" + ], + "ignored-numbers": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "16", + "32", + "64", + "128", + "255", + "256", + "512", + "1024", + "2048", + "4096", + "8192", + "16384", + "32768", + "65536", + "24", + "60", + "12", + "365", + "366", + "31", + "30", + "28", + "29", + "7", + "86400", + "3600", + "1800", + "1440", + "900", + "300", + "1000", + "100", + "1000", + "1000000", + "127", + "255", + "32767", + "65535", + "2147483647", + "4294967295", + "9223372036854775807", + "18446744073709551615", + "-128", + "-32768", + "-2147483648", + "-9223372036854775808", + "200", + "201", + "204", + "400", + "401", + "403", + "404", + "500", + "503", + "0644", + "0666", + "0755", + "0777", + "0600", + "0400", + "1e3", + "1e6", + "1e9" + ] + }, + "perfsprint": { + "bool-format": true, + "err-error": true, + "error-format": true, + "errorf": true, + "hex-format": true, + "int-conversion": true, + "integer-format": true, + "sprintf1": true, + "strconcat": true, + "string-format": true + }, + "revive": { + "rules": [ + { + "arguments": [ + [ + "JSON", + "ID", + "URL", + "API", + "UUID", + "UI", + "UID" + ] + ], + "disabled": false, + "name": "var-naming" + } + ] + } + } + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..b4c8a6e --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,5 @@ +package constants + +const ( + LatestGoVersion = "1.25.0" +) diff --git a/internal/create/create.go b/internal/create/create.go new file mode 100644 index 0000000..ef4f218 --- /dev/null +++ b/internal/create/create.go @@ -0,0 +1 @@ +package create diff --git a/internal/fsutils/fsutils.go b/internal/fsutils/fsutils.go new file mode 100644 index 0000000..1d702db --- /dev/null +++ b/internal/fsutils/fsutils.go @@ -0,0 +1,31 @@ +package fsutils + +import ( + "fmt" + "os" + "path/filepath" +) + +type CreateFilesJob struct { + Filename string `json:"filename"` + Content string `json:"content"` +} + +type CreateJob []*CreateFilesJob + +func (cj CreateJob) Execute() error { + for _, job := range cj { + if err := os.MkdirAll(filepath.Dir(job.Filename), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + err := os.WriteFile(job.Filename, []byte(job.Content), 0644) + switch { + case os.IsExist(err): + fmt.Printf("Warning: file already exists at %s\n", job.Filename) + case err != nil: + return fmt.Errorf("failed to create file: %w", err) + } + } + return nil +} diff --git a/internal/fsutils/new.go b/internal/fsutils/new.go new file mode 100644 index 0000000..5a4740b --- /dev/null +++ b/internal/fsutils/new.go @@ -0,0 +1,20 @@ +package fsutils + +import ( + "encoding/json" + "errors" +) + +func NewJobFromJSON(b []byte) (*CreateJob, error) { + if len(b) == 0 { + return nil, errors.New("empty byte array") + } + + var cj = CreateJob{} + err := json.Unmarshal(b, &cj) + if err != nil { + return nil, err + } + + return &cj, nil +} diff --git a/internal/gen-ts-types/gen_ts_test.go b/internal/gen-ts-types/gen_ts_test.go new file mode 100644 index 0000000..4a5f3e1 --- /dev/null +++ b/internal/gen-ts-types/gen_ts_test.go @@ -0,0 +1 @@ +package gentstypes_test diff --git a/internal/gen-ts-types/generator.go b/internal/gen-ts-types/generator.go new file mode 100644 index 0000000..31b12bd --- /dev/null +++ b/internal/gen-ts-types/generator.go @@ -0,0 +1,370 @@ +package gentstypes + +import ( + "bytes" + "errors" + "fmt" + "go/ast" + "go/doc" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.hlmpn.dev/pkg/go-logger" +) + +// packOut is a small struct carrying per-package output data. + type packOut struct { + pkgName string + content []byte + } + + // Generate is the entry point for generating TypeScript .d.ts types from Go types. + // Both input and output are required. The caller (CLI) is responsible for setting default values. + // - input: a go package import path (e.g., "github.com/user/pkg"), a local path ("./local/dir"), + // a single file ("/path/to/file.go"), or package.Type pattern similar to go doc ("fmt.Stringer"). + // - output: a .d.ts file path or a directory. If directory, files will be named "packagename.d.ts". + // This function never exits or panics; it returns errors with clear, human-readable messages. + func Generate(input string, output string) error { + if input == "" { + return errors.New("input is required, provide a go package path, local dir/file, or package.Type") + } + if output == "" { + return errors.New("output is required, provide a .d.ts file or a directory") + } + + // Determine if output is a single file or directory + outLower := strings.ToLower(output) + isSingleFile := strings.HasSuffix(outLower, ".d.ts") + + // Prepare output target + // If dir: ensure it's empty; if not empty, remove it in the simplest way, then re-create. + createdDir := false + switch { + case isSingleFile: + dir := filepath.Dir(output) + err := os.MkdirAll(dir, 0o755) + if err != nil { + return errors.New("failed to create output directory") + } + default: + exists, notEmpty, statErr := checkDirStatus(output) + if statErr != nil { + return errors.New("failed to check output directory status") + } + switch { + case exists && notEmpty: + rmErr := os.RemoveAll(output) + if rmErr != nil { + return errors.New("failed to clean output directory") + } + mkErr := os.MkdirAll(output, 0o755) + if mkErr != nil { + return errors.New("failed to create output directory after cleaning") + } + createdDir = true + case exists && !notEmpty: + createdDir = true + default: + mkErr := os.MkdirAll(output, 0o755) + if mkErr != nil { + return errors.New("failed to create output directory") + } + createdDir = true + } + } + + // Load package(s) via loader + loadRes, err := loadFromInput(input) + if err != nil { + if !isSingleFile && createdDir { + _ = os.RemoveAll(output) + } + return err + } + if len(loadRes) == 0 { + if !isSingleFile && createdDir { + _ = os.RemoveAll(output) + } + return errors.New("no packages found for the given input") + } + + // Generate for each package + results := make([]packOut, 0, len(loadRes)) + for _, pkg := range loadRes { + // Build doc.Package to get comments + docPkg := doc.New(pkg.ASTPkg, pkg.ImportPath, doc.AllDecls) + + typeDocMap := buildTypeDocMap(docPkg) + registry := buildTypeRegistry(pkg.ASTPkg) + gCtx := &genContext{ + fset: pkg.Fset, + pkgName: pkg.PkgName, + importPath: pkg.ImportPath, + registry: registry, + typeDocMap: typeDocMap, + typeFilter: pkg.TypeFilter, // optional + seenInline: map[string]bool{}, + seenTypes: map[string]bool{}, + parentPkg: pkg, + pkgDoc: docPkg, + pkgsByName: map[string]*ast.Package{pkg.PkgName: pkg.ASTPkg}, + importedPkg: map[string]string{}, + } + + // Build output buffer for the package + buf := &bytes.Buffer{} + // Header comment + writeHeader(buf, pkg.PkgName, pkg.ImportPath) + + // Determine which types to output + typeSpecs := collectTypeSpecs(pkg.ASTPkg) + if len(typeSpecs) == 0 { + logger.Warnf("no types found in package %s", pkg.PkgName) + } + + // Filter by specific type if input includes package.Type + filtered := typeSpecs + if pkg.TypeFilter != "" { + f := make(map[string]*ast.TypeSpec) + for n, ts := range typeSpecs { + if n == pkg.TypeFilter { + f[n] = ts + } + } + filtered = f + if len(filtered) == 0 { + logger.Warnf("no matching type named %s in package %s", pkg.TypeFilter, pkg.PkgName) + } + } + + // For deterministic order + names := make([]string, 0, len(filtered)) + for name := range filtered { + names = append(names, name) + } + sortStrings(names) + + // Emit types + for _, name := range names { + spec := filtered[name] + + // Type-level ignore via special comment "ts ignore //" + ignore := hasTSIgnoreType(spec) + switch { + case ignore: + continue + default: + } + + // Use GoDoc comments above the type if available + docLines := gCtx.typeDocMap[name] + + // Determine struct vs alias + switch t := spec.Type.(type) { + case *ast.StructType: + emitDocLines(buf, docLines) + generateInterface(buf, gCtx, name, t) + buf.WriteString("\n") + default: + emitDocLines(buf, docLines) + tsType := gCtx.exprToTSType(t, fieldContext{ + jsonName: "", + isPtr: false, + forceNullable: false, + omitempty: false, + omitzero: false, + overrideTSType: "", + insideStruct: false, + tsTagHasNullable: false, + hasExplicitTSOrType: false, + }) + stmt := fmt.Sprintf("export type %s = %s;\n\n", name, tsType) + buf.WriteString(stmt) + } + } + + results = append(results, packOut{pkgName: pkg.PkgName, content: buf.Bytes()}) + } + + // Write outputs + switch { + case isSingleFile: + var all bytes.Buffer + for i, r := range results { + if i > 0 { + all.WriteString("\n") + } + // Add a package section header + all.WriteString("// =====================================================\n") + all.WriteString("// Package: " + r.pkgName + "\n") + all.WriteString("// =====================================================\n\n") + all.Write(r.content) + } + + err := os.WriteFile(output, all.Bytes(), 0o644) + if err != nil { + return errors.New("failed to write output file") + } + return nil + default: + writeErr := writePackagesToDir(output, results) + if writeErr != nil { + _ = os.RemoveAll(output) + return writeErr + } + return nil + } + } + + // checkDirStatus checks whether a directory exists and whether it is empty. + func checkDirStatus(path string) (exists bool, notEmpty bool, err error) { + info, statErr := os.Stat(path) + if statErr != nil { + switch { + case os.IsNotExist(statErr): + return false, false, nil + default: + return false, false, statErr + } + } + switch { + case !info.IsDir(): + return false, false, errors.New("output path exists but is not a directory") + default: + } + entries, readErr := os.ReadDir(path) + if readErr != nil { + return true, false, readErr + } + return true, len(entries) > 0, nil + } + + // writePackagesToDir writes each package content into a file named "packagename.d.ts". + func writePackagesToDir(dir string, results []packOut) error { + for _, r := range results { + filename := filepath.Join(dir, r.pkgName+".d.ts") + err := os.WriteFile(filename, r.content, 0o644) + if err != nil { + return errors.New("failed to write package file") + } + } + return nil + } + + // writeHeader emits a standard header for generated content. + func writeHeader(buf *bytes.Buffer, pkgName, importPath string) { + buf.WriteString("// Code generated by go-ts-types. DO NOT EDIT.\n") + buf.WriteString("// Package: " + pkgName + "\n") + if importPath != "" { + buf.WriteString("// Import Path: " + importPath + "\n") + } + buf.WriteString("\n") + } + + func emitDocLines(buf *bytes.Buffer, docLines []string) { + if len(docLines) == 0 { + return + } + for _, l := range docLines { + if l == "" { + buf.WriteString("//\n") + continue + } + buf.WriteString("// " + l + "\n") + } + } + + // hasTSIgnoreType detects the exact "ts ignore //" directive for type declarations. + // It must be the last line before the "type Name T" spec and exactly equal to "ts ignore //". + func hasTSIgnoreType(spec *ast.TypeSpec) bool { + // In Go AST, doc comments can be attached to GenDecl.Doc or TypeSpec.Doc. + texts := make([]string, 0, 4) + if spec.Doc != nil { + for _, c := range spec.Doc.List { + line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + // For block comments the parser includes /* */; normalize to lines. + if strings.HasPrefix(c.Text, "/*") { + lines := strings.Split(strings.Trim(c.Text, "/*"), "\n") + for _, ln := range lines { + t := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(ln, "//"), "*")) + if t != "" { + texts = append(texts, t) + } + } + continue + } + texts = append(texts, line) + } + } + if len(texts) == 0 { + return false + } + last := texts[len(texts)-1] + switch { + case last == "ts ignore //": + return true + default: + return false + } + } + + // buildTypeDocMap extracts doc comments for each type via go/doc. + func buildTypeDocMap(pkg *doc.Package) map[string][]string { + m := make(map[string][]string) + if pkg == nil { + return m + } + for _, t := range pkg.Types { + name := t.Name + docText := strings.TrimSpace(t.Doc) + if docText == "" { + continue + } + lines := strings.Split(docText, "\n") + m[name] = lines + } + return m + } + + // buildTypeRegistry constructs a registry of type specs in the package. + func buildTypeRegistry(astPkg *ast.Package) map[string]*ast.TypeSpec { + reg := make(map[string]*ast.TypeSpec) + if astPkg == nil { + return reg + } + for _, f := range astPkg.Files { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok { + continue + } + if gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + reg[ts.Name.Name] = ts + } + } + } + return reg + } + + // collectTypeSpecs collects all type specs in the package by name. + func collectTypeSpecs(astPkg *ast.Package) map[string]*ast.TypeSpec { + return buildTypeRegistry(astPkg) + } + + // sortStrings sorts a list of strings. + func sortStrings(ss []string) { + if len(ss) < 2 { + return + } + sort.Strings(ss) + } \ No newline at end of file diff --git a/internal/gen-ts-types/loader.go b/internal/gen-ts-types/loader.go new file mode 100644 index 0000000..9fc2863 --- /dev/null +++ b/internal/gen-ts-types/loader.go @@ -0,0 +1,185 @@ + package gentstypes + + import ( + "errors" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + + "golang.org/x/tools/go/packages" + ) + + // pkgData represents one loaded Go package with AST and metadata. + type pkgData struct { + PkgName string + ImportPath string + Fset *token.FileSet + ASTPkg *ast.Package + TypeFilter string + } + + // loadFromInput loads and parses Go package(s) for a given input string. + // It supports: + // - a single .go file: parses its directory and builds the package from all non-test files in the same package. + // - a local directory path: parses as a package. + // - an import path: uses go/packages to locate, then parses files with go/parser. + // - an import path with ".Type" at the end: loads package and sets TypeFilter to that type name. + // Only one primary package is loaded and returned as a single-element slice. + func loadFromInput(input string) ([]pkgData, error) { + // Detect file input + if strings.HasSuffix(strings.ToLower(input), ".go") { + return loadFromFile(input) + } + + // Detect probable local path (absolute or relative) + if isLikelyPath(input) { + return loadFromDir(input) + } + + // Otherwise treat as import path or pkg.Type + return loadFromImportOrType(input) + } + + func isLikelyPath(p string) bool { + switch { + case strings.HasPrefix(p, "./"): + return true + case strings.HasPrefix(p, "../"): + return true + case strings.HasPrefix(p, "/"): + return true + case strings.Contains(p, string(os.PathSeparator)): + return true + default: + return false + } + } + + func loadFromFile(path string) ([]pkgData, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, errors.New("failed to resolve file path") + } + info, statErr := os.Stat(abs) + if statErr != nil || info.IsDir() { + return nil, errors.New("file path is invalid or points to a directory") + } + dir := filepath.Dir(abs) + return loadFromDir(dir) + } + + func loadFromDir(dir string) ([]pkgData, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, errors.New("failed to resolve directory path") + } + info, statErr := os.Stat(abs) + if statErr != nil || !info.IsDir() { + return nil, errors.New("directory path is invalid") + } + + // Parse all files in the directory with go/parser + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, abs, func(fi os.FileInfo) bool { + name := fi.Name() + // skip test files + if strings.HasSuffix(name, "_test.go") { + return false + } + return strings.HasSuffix(name, ".go") + }, parser.ParseComments) + if err != nil { + return nil, errors.New("failed to parse directory") + } + if len(pkgs) == 0 { + return nil, errors.New("no go packages found in the directory") + } + + // Select primary package (ignore *_test package names) + var chosen *ast.Package + var pkgName string + for name, p := range pkgs { + if strings.HasSuffix(name, "_test") { + continue + } + chosen = p + pkgName = name + break + } + if chosen == nil { + for name, p := range pkgs { + chosen = p + pkgName = name + break + } + } + + return []pkgData{{ + PkgName: pkgName, + ImportPath: abs, // best-effort for local; not module path + Fset: fset, + ASTPkg: chosen, + TypeFilter: "", + }}, nil + } + + func loadFromImportOrType(path string) ([]pkgData, error) { + // Support pkg.Type filter + imp, typeName := splitImportType(path) + + cfg := &packages.Config{ + Mode: packages.NeedName | + packages.NeedFiles | + packages.NeedCompiledGoFiles | + packages.NeedModule | + packages.NeedSyntax, + } + pkgs, err := packages.Load(cfg, imp) + if err != nil { + return nil, errors.New("failed to load package by import path") + } + if packages.PrintErrors(pkgs) > 0 { + return nil, errors.New("package contains build or load errors") + } + if len(pkgs) == 0 { + return nil, errors.New("no packages matched the import path") + } + + p := pkgs[0] + // Re-parse to ensure we have comments collected via parser.ParseComments (packages.Syntax is already parsed but comments mode typically enabled; we keep usage consistent) + fset := token.NewFileSet() + astFiles := make(map[string]*ast.File) + for _, fname := range p.GoFiles { + file, perr := parser.ParseFile(fset, fname, nil, parser.ParseComments) + if perr != nil { + return nil, errors.New("failed to parse package files") + } + astFiles[fname] = file + } + astPkg := &ast.Package{ + Name: p.Name, + Files: astFiles, + } + return []pkgData{{ + PkgName: p.Name, + ImportPath: p.PkgPath, + Fset: fset, + ASTPkg: astPkg, + TypeFilter: typeName, + }}, nil + } + + func splitImportType(s string) (importPath string, typeName string) { + // If there is a dot after the last slash, interpret as pkg.Type + lastSlash := strings.LastIndex(s, "/") + lastDot := strings.LastIndex(s, ".") + switch { + case lastDot > lastSlash && lastDot != -1: + return s[:lastDot], s[lastDot+1:] + default: + return s, "" + } + } \ No newline at end of file diff --git a/internal/gen-ts-types/types.go b/internal/gen-ts-types/types.go new file mode 100644 index 0000000..4a84b22 --- /dev/null +++ b/internal/gen-ts-types/types.go @@ -0,0 +1,623 @@ +package gentstypes + +import ( + "bytes" + "fmt" + "go/ast" + "go/doc" + "go/token" + "reflect" + "strings" + + "gopkg.hlmpn.dev/pkg/go-logger" +) + +// We centralize number and basic type mapping here. +var basicTypeMap = map[string]string{ + "string": "string", + "bool": "boolean", + "byte": "number", + "rune": "number", + "int": "number", + "int8": "number", + "int16": "number", + "int32": "number", + "int64": "number", + "uint": "number", + "uint8": "number", + "uint16": "number", + "uint32": "number", + "uint64": "number", + "uintptr": "number", + "float32": "number", + "float64": "number", + "complex64": "number", + "complex128": "number", + "error": "string", // often marshaled as string; safer than any + "any": "any", +} + +// genContext carries state for generating a single package's TS output. +type genContext struct { + fset *token.FileSet + pkgName string + importPath string + registry map[string]*ast.TypeSpec + typeDocMap map[string][]string + typeFilter string + seenInline map[string]bool + seenTypes map[string]bool + parentPkg pkgData + pkgDoc *doc.Package + pkgsByName map[string]*ast.Package + importedPkg map[string]string // alias -> full import path (best-effort) +} + +// fieldContext captures field-specific options for TS type generation. +type fieldContext struct { + jsonName string + isPtr bool + forceNullable bool + omitempty bool + omitzero bool + overrideTSType string + insideStruct bool + tsTagHasNullable bool + hasExplicitTSOrType bool +} + +// generateInterface emits "export interface Name { ... }" for a struct. +func generateInterface(buf *bytes.Buffer, g *genContext, name string, st *ast.StructType) { + fields := collectStructFields(g, st) + buf.WriteString(fmt.Sprintf("export interface %s {\n", name)) + for _, f := range fields { + buf.WriteString(" ") + buf.WriteString(f) + buf.WriteString("\n") + } + buf.WriteString("}\n") +} + +// collectStructFields returns a slice of "prop?: T" or "prop: T | null" lines for a struct type. +func collectStructFields(g *genContext, st *ast.StructType) []string { + out := []string{} + if st.Fields == nil || len(st.Fields.List) == 0 { + return out + } + for _, fld := range st.Fields.List { + // Embedded or named? + switch { + case len(fld.Names) == 0: + // Embedded field + embeddedLines := expandEmbeddedField(g, fld) + switch { + case len(embeddedLines) == 0: + continue + default: + out = append(out, embeddedLines...) + continue + } + default: + // Named fields can have multiple names sharing same type: handle each + for _, nameIdent := range fld.Names { + line := generateFieldLine(g, nameIdent.Name, fld) + if line == "" { + continue + } + out = append(out, line) + } + } + } + return out +} + +// expandEmbeddedField tries to flatten embedded fields. +// We flatten when: +// - anonymous struct type: inline its fields +// - named type defined in the same package and is a struct: inline its fields +func expandEmbeddedField(g *genContext, fld *ast.Field) []string { + t := fld.Type + switch tt := t.(type) { + case *ast.Ident: + // Same-package type? + spec := g.registry[tt.Name] + switch { + case spec == nil: + return nil + default: + st, ok := spec.Type.(*ast.StructType) + switch { + case !ok: + return nil + default: + return collectStructFields(g, st) + } + } + case *ast.StarExpr: + // Pointer to ident or struct + switch ut := tt.X.(type) { + case *ast.Ident: + spec := g.registry[ut.Name] + switch { + case spec == nil: + return nil + default: + st, ok := spec.Type.(*ast.StructType) + switch { + case !ok: + return nil + default: + return collectStructFields(g, st) + } + } + case *ast.StructType: + return collectStructFields(g, ut) + default: + return nil + } + case *ast.StructType: + return collectStructFields(g, tt) + default: + return nil + } +} + +// generateFieldLine generates one TS property line for a single named field. +func generateFieldLine(g *genContext, goFieldName string, fld *ast.Field) string { + // Field-level comment "ts ignore //" detection with logger warning rules + hasIgnoreComment := hasTSIgnoreOnField(fld) + tags := parseStructTag(fld) + hasTsTag := tags.ts != "" + hasTsTypeTag := tags.tsType != "" + + switch { + case hasIgnoreComment && (hasTsTag || hasTsTypeTag): + logger.Warnf("field %s has a 'ts ignore //' comment and ts or ts_type tag; ignoring the comment", goFieldName) + case hasIgnoreComment: + return "" + default: + } + + // Reflect tags + if tags.ignore { + return "" + } + // JSON tag "-" is ignore + if tags.jsonName == "-" { + return "" + } + + // Determine property name (from json tag or Go field) + propName := tags.jsonName + switch { + case propName == "": + propName = goFieldName + default: + } + + // Determine type characteristics + isPtr := isPtrType(fld.Type) + isSlice := isSliceType(fld.Type) + isMap := isMapType(fld.Type) + + // Optional/nullable decision + // Priority: ts tag overrides json. Follow the detailed rules mentioned. + optional := false + nullable := false + + // Start with base derived from kind + if isPtr { + // pointer default + optional = true + nullable = true + } + if isSlice || isMap { + // slices and maps can be nil => nullable by default + nullable = true + } + + // Apply json omitempty/omitzero -> optional + if tags.omitempty || tags.omitzero { + optional = true + } + + // Apply ts:"nullable" => required and nullable for non-pointer/non-omitempty as per rules + if tags.tsNullable { + nullable = true + } + + // Compose type string + ctx := fieldContext{ + jsonName: propName, + isPtr: isPtr, + forceNullable: nullable, + omitempty: tags.omitempty, + omitzero: tags.omitzero, + overrideTSType: tags.tsType, + insideStruct: true, + tsTagHasNullable: tags.tsNullable, + hasExplicitTSOrType: hasTsTag || hasTsTypeTag, + } + tsType := g.exprToTSType(fld.Type, ctx) + + // Property line + qs := "" + if optional { + qs = "?" + } + // Append null explicitly if nullable + if nullable && !strings.Contains(tsType, "| null") { + tsType = tsType + " | null" + } + + return fmt.Sprintf("%s%s: %s;", propName, qs, tsType) +} + +type parsedTags struct { + jsonName string + omitempty bool + omitzero bool + + // ts:"ignore" or ts:"nullable" + ts string + ignore bool + tsNullable bool + tsType string + otherRawTag string +} + +// parseStructTag extracts json, ts, ts_type directives from field tag. +func parseStructTag(fld *ast.Field) parsedTags { + var pt parsedTags + if fld.Tag == nil { + return pt + } + // BasicLit.Value is quoted with backticks or double quotes. Strip them. + raw := fld.Tag.Value + trim := strings.Trim(raw, "`\"") + st := reflect.StructTag(trim) + + jsonTag := st.Get("json") + tsTag := st.Get("ts") + tsType := st.Get("ts_type") + + // json tag parse + if jsonTag != "" { + parts := strings.Split(jsonTag, ",") + switch { + case len(parts) > 0: + pt.jsonName = parts[0] + default: + } + for _, p := range parts[1:] { + switch p { + case "omitempty": + pt.omitempty = true + case "omitzero": // Go 1.23 addition + pt.omitzero = true + } + } + } + + // ts tag parse + if tsTag != "" { + pt.ts = tsTag + if tsTag == "ignore" { + pt.ignore = true + } + if tsTag == "nullable" { + pt.tsNullable = true + } + } + + // ts_type override + if tsType != "" { + pt.tsType = strings.TrimSpace(tsType) + } + + pt.otherRawTag = trim + return pt +} + +// hasTSIgnoreOnField detects a field-level "ts ignore //" comment (exact) on either Doc or Comment. +func hasTSIgnoreOnField(fld *ast.Field) bool { + lines := []string{} + + if fld.Doc != nil { + for _, c := range fld.Doc.List { + lines = append(lines, normalizeComment(c.Text)...) + } + } + if fld.Comment != nil { + for _, c := range fld.Comment.List { + lines = append(lines, normalizeComment(c.Text)...) + } + } + + if len(lines) == 0 { + return false + } + last := lines[len(lines)-1] + switch { + case last == "ts ignore //": + return true + default: + return false + } +} + +func normalizeComment(text string) []string { + out := []string{} + switch { + case strings.HasPrefix(text, "/*"): + trim := strings.Trim(text, "/*") + parts := strings.Split(trim, "\n") + for _, p := range parts { + p = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(p), "*")) + if p != "" { + out = append(out, p) + } + } + default: + line := strings.TrimSpace(strings.TrimPrefix(text, "//")) + out = append(out, line) + } + return out +} + +func isPtrType(expr ast.Expr) bool { + _, ok := expr.(*ast.StarExpr) + return ok +} + +func isSliceType(expr ast.Expr) bool { + _, ok := expr.(*ast.ArrayType) + return ok +} + +func isMapType(expr ast.Expr) bool { + _, ok := expr.(*ast.MapType) + return ok +} + +// exprToTSType converts an AST expression to a TypeScript type string. +// The ctx provides field-level hints (tags, optional/nullable overrides). +func (g *genContext) exprToTSType(expr ast.Expr, ctx fieldContext) string { + switch t := expr.(type) { + case *ast.Ident: + // Built-in or named type + name := t.Name + // Special-cases + switch name { + case "any": + return "any" + default: + } + // Basic type mapping + if ts, ok := basicTypeMap[name]; ok { + return ts + } + // Named type defined in same package + if g.registry[name] != nil { + return name + } + // Known special aliases + switch name { + case "Time": + // time.Time would be SelectorExpr usually. If ident used here, we don't know; leave as any. + return "any" + default: + return "any" + } + case *ast.ArrayType: + // Slice or array + elt := t.Elt + // Special case: []byte + if isIdentByte(elt) { + // []byte as base64 string by default; allow ts_type override + switch { + case ctx.overrideTSType != "": + return tsOverrideToType(ctx.overrideTSType) + default: + return "string" + } + } + eltTs := g.exprToTSType(elt, fieldContext{ + jsonName: ctx.jsonName, + isPtr: false, + forceNullable: false, + omitempty: ctx.omitempty, + omitzero: ctx.omitzero, + overrideTSType: "", + insideStruct: ctx.insideStruct, + tsTagHasNullable: ctx.tsTagHasNullable, + hasExplicitTSOrType: ctx.hasExplicitTSOrType, + }) + return fmt.Sprintf("%s[]", wrapIfUnion(eltTs)) + case *ast.StarExpr: + // Pointer: unwrap and map + ts := g.exprToTSType(t.X, fieldContext{ + jsonName: ctx.jsonName, + isPtr: true, + forceNullable: ctx.forceNullable, + omitempty: ctx.omitempty, + omitzero: ctx.omitzero, + overrideTSType: ctx.overrideTSType, + insideStruct: ctx.insideStruct, + tsTagHasNullable: ctx.tsTagHasNullable, + hasExplicitTSOrType: ctx.hasExplicitTSOrType, + }) + return ts + case *ast.MapType: + // map[K]V => Record + keyTs := g.mapKeyToTS(t.Key) + valTs := g.exprToTSType(t.Value, fieldContext{ + jsonName: ctx.jsonName, + isPtr: false, + forceNullable: false, + omitempty: ctx.omitempty, + omitzero: ctx.omitzero, + overrideTSType: "", + insideStruct: ctx.insideStruct, + tsTagHasNullable: ctx.tsTagHasNullable, + hasExplicitTSOrType: ctx.hasExplicitTSOrType, + }) + return fmt.Sprintf("Record<%s, %s>", keyTs, valTs) + case *ast.StructType: + // Anonymous inline struct + fields := collectStructFields(g, t) + if len(fields) == 0 { + return "{}" + } + var b strings.Builder + b.WriteString("{ ") + for i, line := range fields { + // line is "prop?: T;" - we need to inline without leading indentation + b.WriteString(strings.TrimSpace(line)) + if i < len(fields)-1 { + b.WriteString(" ") + } + } + b.WriteString(" }") + return b.String() + case *ast.SelectorExpr: + // Qualified identifier: pkg.Type + pkgIdent, _ := t.X.(*ast.Ident) + sel := t.Sel.Name + pkgName := "" + if pkgIdent != nil { + pkgName = pkgIdent.Name + } + _ = pkgName + qname := sel + if pkgName != "" { + qname = pkgName + "." + sel + } + + // Special-cases + switch qname { + case "json.RawMessage", "encoding/json.RawMessage": + if ctx.overrideTSType == "array" { + return "any[]" + } + if ctx.overrideTSType == "object" { + return "Record" + } + return "any" + case "time.Time": + return "string" + default: + // External types: map to any + return "any" + } + case *ast.InterfaceType: + return "any" + case *ast.FuncType: + return "any" + case *ast.ChanType: + return "any" + case *ast.Ellipsis: + eltTs := g.exprToTSType(t.Elt, fieldContext{}) + return fmt.Sprintf("%s[]", wrapIfUnion(eltTs)) + case *ast.IndexExpr: + // generic type usage T[U] => map to 'any' for simplicity + return "any" + case *ast.IndexListExpr: + return "any" + default: + return "any" + } +} + +func wrapIfUnion(ts string) string { + if strings.Contains(ts, " ") || strings.Contains(ts, "|") || strings.HasPrefix(ts, "{") { + return "(" + ts + ")" + } + return ts +} + +func isIdentByte(expr ast.Expr) bool { + id, ok := expr.(*ast.Ident) + if !ok { + return false + } + return id.Name == "byte" || id.Name == "uint8" +} + +func tsOverrideToType(v string) string { + switch v { + case "string": + return "string" + case "number": + return "number" + case "boolean": + return "boolean" + case "object": + return "Record" + case "array": + return "any[]" + case "any": + return "any" + case "Uint8Array": + return "Uint8Array" + case "ArrayBuffer": + return "ArrayBuffer" + default: + return "any" + } +} + +func (g *genContext) mapKeyToTS(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + name := t.Name + switch name { + case "string": + return "string" + case "bool": + // TS index signatures do not allow boolean keys, but instruction requires to use T as keys. + // We still return 'string' to ensure valid TS, as JSON keys are strings. + return "string" + default: + if _, ok := basicTypeMap[name]; ok { + // number-like keys become number + switch name { + case "string": + return "string" + default: + return "number" + } + } + // Unknown key type => string + return "string" + } + case *ast.StarExpr: + // pointers cannot be map keys in Go; fallback + return "string" + case *ast.SelectorExpr: + // qualified names used as map keys are often handled by encoding as string + return "string" + default: + return "string" + } +} + +// Additional helper: safe string representation for AST expr (debug only) +func _debugExpr(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.ArrayType: + return "[]" + _debugExpr(t.Elt) + case *ast.StarExpr: + return "*" + _debugExpr(t.X) + case *ast.MapType: + return "map[" + _debugExpr(t.Key) + "]" + _debugExpr(t.Value) + case *ast.SelectorExpr: + return _debugExpr(t.X) + "." + t.Sel.Name + case *ast.StructType: + return "struct{...}" + default: + return fmt.Sprintf("%T", expr) + } +} diff --git a/internal/gen-ts-types/write.go b/internal/gen-ts-types/write.go new file mode 100644 index 0000000..9e21d1e --- /dev/null +++ b/internal/gen-ts-types/write.go @@ -0,0 +1,23 @@ +package gentstypes + +import ( + "bytes" + "go/ast" + "go/doc" + "go/token" + "sort" + "strings" +) + +// This file primarily hosts additional helpers. No new public API here. + +// The following ensures imports are referenced to satisfy the compiler for +// packages imported in other files without nested imports. +var ( + _ = bytes.NewBuffer + _ = token.ILLEGAL + _ = doc.New + _ = ast.File{} + _ = sort.Strings + _ = strings.TrimSpace +) diff --git a/internal/stdlibtemplate/router.go b/internal/stdlibtemplate/router.go new file mode 100644 index 0000000..55ec4da --- /dev/null +++ b/internal/stdlibtemplate/router.go @@ -0,0 +1,18 @@ +package stdlibtemplate + +import ( + "errors" + + "github.com/luigimorel/gogen/internal/fsutils" +) + +func CreateRouterSetup() error { + job, err := fsutils.NewJobFromJSON([]byte(routerPkgTemplate)) + switch { + case err != nil: + return err + case job == nil: + return errors.New("job is nil") + } + return job.Execute() +} diff --git a/internal/stdlibtemplate/router_test.go b/internal/stdlibtemplate/router_test.go new file mode 100644 index 0000000..f628474 --- /dev/null +++ b/internal/stdlibtemplate/router_test.go @@ -0,0 +1,98 @@ +package stdlibtemplate_test + +import ( + "io/fs" + "log" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "github.com/luigimorel/gogen/internal/stdlibtemplate" +) + +func init() { + // Find project root by looking for go.mod + for { + _, err := os.Stat("go.mod") + if os.IsNotExist(err) { + // go.mod not found, try parent directory + if err := os.Chdir(".."); err != nil { + log.Fatalf("init: failed to change directory: %v", err) + } + // Check if we are at the root of the filesystem + cwd, _ := os.Getwd() + if cwd == "/" || cwd == filepath.Dir(cwd) { + log.Fatalf("init: go.mod not found in any parent directory") + } + continue + } + if err != nil { + log.Fatalf("init: error checking for go.mod: %v", err) + } + + return + } +} + +func TestCreateRouterSetup(t *testing.T) { + // Create a temporary directory for the test to run in. + // This is safer than creating files in the project root. + tempDir := t.TempDir() + + // Get the current working directory to return to it after the test. + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + // Change to the temporary directory. + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + // Use t.Cleanup to ensure we change back to the original directory. + t.Cleanup(func() { + if err := os.Chdir(originalWD); err != nil { + t.Errorf("failed to change back to original directory: %v", err) + } + }) + + // Run the function to be tested. This will create the 'router' dir inside the tempDir. + if err := stdlibtemplate.CreateRouterSetup(); err != nil { + t.Fatalf("CreateRouterSetup() failed: %v", err) + } + + // Define the expected file structure based on the provided tree. + expectedFiles := []string{ + "router/handler.go", + "router/ocsp.go", + "router/redirects.go", + "router/router.go", + "router/serve.go", + } + sort.Strings(expectedFiles) + + var actualFiles []string + err = filepath.Walk("router", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + // Normalize path separators for consistent comparison. + actualFiles = append(actualFiles, filepath.ToSlash(path)) + } + return nil + }) + + if err != nil { + t.Fatalf("failed to walk created 'router' directory: %v", err) + } + sort.Strings(actualFiles) + + // Compare the actual file list with the expected file list. + if !reflect.DeepEqual(expectedFiles, actualFiles) { + t.Errorf(`file structure mismatch: +Expected: %v +Actual: %v`, expectedFiles, actualFiles) + } +} diff --git a/internal/stdlibtemplate/templates.go b/internal/stdlibtemplate/templates.go new file mode 100644 index 0000000..9d859ef --- /dev/null +++ b/internal/stdlibtemplate/templates.go @@ -0,0 +1,3 @@ +package stdlibtemplate + +const routerPkgTemplate = `[{"filename":"router/handler.go","content":"package router\n\nimport \"net/http\"\n\nvar serverHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t// Enable for CORS\n\t// setCorsHeaders(w)\n\n\t// Enable for logging\n\t// logRequest(r)\n\n\tredirect(w, r)\n\n\tRouter.ServeHTTP(w, r)\n\n})\n\nvar httpTohttpsRedirectHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\ttarget := \"https://\" + r.Host + r.URL.Path\n\tif len(r.URL.RawQuery) \u003e 0 {\n\t\ttarget += \"?\" + r.URL.RawQuery\n\t}\n\thttp.Redirect(w, r, target, http.StatusMovedPermanently)\n\n\tRouter.ServeHTTP(w, r)\n})\n"},{"filename":"router/ocsp.go","content":"package router\n\nimport (\n\t\"bytes\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sync\"\n\n\t\"golang.org/x/crypto/ocsp\"\n)\n\n// Global variable for cipher suites\nvar cipherSuitesTLS2 = []uint16{\n\ttls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,\n\ttls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,\n\ttls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,\n\ttls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,\n\ttls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,\n\ttls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,\n\ttls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,\n}\n\nvar cipherSuitesTLS3 = []uint16{\n\ttls.TLS_AES_128_GCM_SHA256,\n\ttls.TLS_AES_256_GCM_SHA384,\n\ttls.TLS_CHACHA20_POLY1305_SHA256,\n}\n\nvar activeSSLCache = newSSLCache()\n\ntype sslCache struct {\n\tsessions map[string]*tls.ClientSessionState\n\tmu sync.RWMutex\n}\n\nfunc newSSLCache() *sslCache {\n\treturn \u0026sslCache{sessions: make(map[string]*tls.ClientSessionState)}\n}\n\nfunc (c *sslCache) Get(sessionKey string) (*tls.ClientSessionState, bool) {\n\tc.mu.RLock()\n\tdefer c.mu.RUnlock()\n\tsession, ok := c.sessions[sessionKey]\n\treturn session, ok\n}\n\nfunc (c *sslCache) Put(sessionKey string, cs *tls.ClientSessionState) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.sessions[sessionKey] = cs\n}\n\nfunc fetchOCSPResponse(cert tls.Certificate) ([]byte, *ocsp.Response, error) {\n\t// Parse the certificate\n\tx509Cert, err := x509.ParseCertificate(cert.Certificate[0])\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Check if the certificate has an OCSP server\n\tif len(x509Cert.OCSPServer) == 0 {\n\t\treturn nil, nil, errors.New(\"no OCSP server in certificate\")\n\t}\n\tx509Cert.OCSPServer[0] = \"http://r3.o.lencr.org\"\n\n\t// Create an OCSP request\n\tocspRequest, err := ocsp.CreateRequest(x509Cert, x509Cert, nil)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Send the OCSP request to the OCSP server\n\tresp, err := http.Post(x509Cert.OCSPServer[0], \"application/ocsp-request\", bytes.NewReader(ocspRequest))\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check the HTTP response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, nil, fmt.Errorf(\"OCSP server returned status %d\", resp.StatusCode)\n\t}\n\n\t// Read the OCSP response\n\tocspResponseBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Parse the OCSP response\n\tocspResponse, err := ocsp.ParseResponse(ocspResponseBytes, nil)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn ocspResponseBytes, ocspResponse, nil\n}\n\nfunc orderProtos(protos []string) []string {\n\tfor _, proto := range protos {\n\t\tif proto == \"h3\" {\n\t\t\treturn protos\n\t\t}\n\t}\n\n\t// If \"h3\" is not found, append \"h3\" and \"h3-29\"\n\treturn append(protos, \"h3\", \"h3-29\")\n}\n"},{"filename":"router/redirects.go","content":"package router\n\nimport \"net/http\"\n\nvar Redirects = map[string]string{\n\t\"/\": \"/index.html\",\n}\n\nfunc redirect(w http.ResponseWriter, r *http.Request) {\n\tredir, ok := Redirects[r.URL.Path]\n\tif ok {\n\t\thttp.Redirect(w, r, redir, http.StatusMovedPermanently)\n\t}\n}\n"},{"filename":"router/router.go","content":"package router\n\nimport \"net/http\"\n\nvar Router = http.NewServeMux()\n"},{"filename":"router/serve.go","content":"package router\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/quic-go/quic-go\"\n\t\"github.com/quic-go/quic-go/http3\"\n\t\"golang.org/x/net/http2\"\n)\n\ntype ServerControl struct {\n\tstart chan bool\n}\n\n//var TLSCerts = []tls.Certificate{certs}\n\n// Quic config\nvar quicConfig = \u0026quic.Config{\n\tMaxIdleTimeout: 10 * time.Minute,\n\tMaxIncomingStreams: 1000,\n\tMaxIncomingUniStreams: 1000,\n\tAllow0RTT: true,\n\tDisablePathMTUDiscovery: false,\n}\n\n// TLS config\nfunc getTLSConfig(certs []tls.Certificate, baseTLSConfig *tls.Config) *tls.Config {\n\treturn \u0026tls.Config{\n\n\t\tGetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {\n\t\t\tconfig := \u0026tls.Config{\n\t\t\t\tCertificates: certs,\n\t\t\t\tClientSessionCache: baseTLSConfig.ClientSessionCache,\n\t\t\t\tDynamicRecordSizingDisabled: baseTLSConfig.DynamicRecordSizingDisabled,\n\t\t\t\tMinVersion: tls.VersionTLS12,\n\t\t\t\tServerName: baseTLSConfig.ServerName,\n\t\t\t\tNextProtos: orderProtos(chi.SupportedProtos),\n\t\t\t}\n\t\t\tif len(chi.SupportedVersions) \u003e 0 {\n\t\t\t\thighestVersion := chi.SupportedVersions[0]\n\t\t\t\tif highestVersion == tls.VersionTLS13 {\n\t\t\t\t\t// Set cipher suites for TLS 1.3\n\t\t\t\t\tconfig.CipherSuites = cipherSuitesTLS3\n\t\t\t\t} else {\n\t\t\t\t\t// Set cipher suites for TLS 1.2\n\t\t\t\t\tconfig.CipherSuites = cipherSuitesTLS2\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn config, nil\n\t\t},\n\t}\n}\n\n// HTTP/2 and HTTP/1.1 Server\nvar http2Server = \u0026http.Server{\n\t//Handler: serverHandler,\n\tReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,\n}\n\n// HTTP/3 Server\nvar http3Server = \u0026http3.Server{\n\tQUICConfig: quicConfig,\n}\n\n// Redirect server for http to https\nvar httpRedirectSrv = \u0026http.Server{\n\tReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,\n}\n\nfunc ServeTLS(addr, httpOnlyAddr, certFile, keyFile string, sni string) error {\n\n\tswitch {\n\tcase addr == \"\":\n\t\treturn errors.New(\"port is required\")\n\tcase httpOnlyAddr == \"\":\n\t\treturn errors.New(\"httpOnlyPort is required\")\n\tcase certFile == \"\":\n\t\treturn errors.New(\"certFile is required\")\n\tcase keyFile == \"\":\n\t\treturn errors.New(\"keyFile is required\")\n\tcase sni == \"\":\n\t\treturn errors.New(\"sni is required\")\n\t}\n\n\thttp2Server.Addr = addr\n\thttp3Server.Addr = addr\n\thttpRedirectSrv.Addr = httpOnlyAddr\n\tcerts, err := InitTLS(keyFile, certFile)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to initialize tls: %w\", err)\n\t}\n\tbaseTLSConfig := \u0026tls.Config{\n\t\tDynamicRecordSizingDisabled: false,\n\t\tClientSessionCache: activeSSLCache,\n\t\tServerName: sni,\n\t}\n\ttlsConfig := getTLSConfig(certs, baseTLSConfig)\n\thttp2Server.TLSConfig = tlsConfig\n\thttp3Server.TLSConfig = http3.ConfigureTLSConfig(tlsConfig)\n\n\thttpRedirectSrv.Addr = httpOnlyAddr\n\n\ttlsConfig.NextProtos = []string{\"h3\", \"h2\", \"http/1.1\"}\n\ttlsConfig.MinVersion = tls.VersionTLS13\n\n\t// enable/disable tracing\n\t// quicConfig.Tracer = qlog.DefaultTracer\n\n\t// Open the listeners\n\tudpAddr, err := net.ResolveUDPAddr(\"udp\", addr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve udp address: %w\", err)\n\t}\n\tudpConn, err := net.ListenUDP(\"udp\", udpAddr)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to listen on udp: %w\", err)\n\t}\n\tlog.Printf(\"UDP connection: %v\", udpConn)\n\t//defer udpConn.Close()\n\n\t// TCP connection\n\ttcpListener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to listen on TCP: %v\", err)\n\t}\n\ttlsListener := tls.NewListener(tcpListener, tlsConfig)\n\n\t// Http to https redirect listener\n\thttponlyTcpListener, err := net.Listen(\"tcp\", httpOnlyAddr)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to listen on TCP: %v\", err)\n\t}\n\n\tlog.Printf(\"UDP connection: %v\", udpConn)\n\n\tcontrol := \u0026ServerControl{\n\t\tstart: make(chan bool, 10),\n\t}\n\tstart := control.start\n\n\t// Set handlers\n\thttp2Server.Handler = serverHandler\n\thttp3Server.Handler = serverHandler\n\thttpRedirectSrv.Handler = httpTohttpsRedirectHandler\n\n\tlog.Printf(\"h3 handler: %v\", http3Server.Handler)\n\tlog.Printf(\"h2 handler: %v\", http2Server.Handler)\n\n\t// Configure the http2 server\n\n\thttp2ServerConfig := \u0026http2.Server{\n\t\tIdleTimeout: 10 * time.Minute, // the amount of time a connection can be idle before it's closed\n\t\tMaxHandlers: 100, // the maximum number of http.Handler invocations simultaneously serving requests\n\t}\n\t// The actual h2 configuration\n\terr = http2.ConfigureServer(http2Server, http2ServerConfig)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to configure http2 server: %v\", err)\n\t}\n\n\t// Run the servers\n\th2Err := make(chan error, 1)\n\th3Err := make(chan error, 1)\n\tkill := make(chan bool, 1)\n\n\t// check udpconn\n\n\tlog.Printf(\"UDP connection: %v\", udpConn)\n\n\tlog.Printf(\"Sendinig start signal\")\n\tstart \u003c- true\n\tstart \u003c- true\n\n\tgo func() {\n\t\t\u003c-start\n\n\t\tlog.Printf(\"udpConn.LocalAddr.String: %s\", udpConn.LocalAddr().String())\n\t\tlog.Printf(\"Starting server HTTP/3\")\n\n\t\th3Err \u003c- http3Server.Serve(udpConn)\n\t\t\u003c-kill\n\n\t}()\n\t// Serve HTTP/1.1 and HTTP/2 requests\n\tgo func() {\n\t\t\u003c-start\n\t\t//logger.LogTLSListener(tlsListener)\n\t\tlog.Printf(\"Serving HTTP/1.1 and HTTP/2\")\n\t\th2Err \u003c- http2Server.Serve(tlsListener)\n\t\t\u003c-kill\n\t}()\n\t// Redirect server\n\tgo func() {\n\t\tif err := httpRedirectSrv.Serve(httponlyTcpListener); err != nil \u0026\u0026 err != http.ErrServerClosed {\n\t\t\tlog.Printf(\"Could not listen on %s: %v\\n\", \":80\", err)\n\t\t}\n\t}()\n\n\t// Create a channel to listen for interrupt signals\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT)\n\t// Wait for an interrupt signal in a separate goroutine\n\tgo func() {\n\t\t\u003c-quit\n\n\t\tkill \u003c- true\n\t\t// Create a deadline to wait for.\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\n\t\thttp2Server.Shutdown(ctx)\n\t\thttp3Server.Close()\n\n\t\tlog.Print(\"Servers stopped gracefully\")\n\t}()\n\tselect {\n\tcase err := \u003c-h2Err:\n\t\treturn fmt.Errorf(\"HTTP/1.1 and HTTP/2 server failed: %w\", err)\n\tcase err := \u003c-h3Err:\n\t\treturn fmt.Errorf(\"HTTP/3 server failed: %w\", err)\n\t}\n\n}\n\nfunc InitTLS(keypath, certpath string) ([]tls.Certificate, error) {\n\tvar pemBlocks = make(map[string][]byte)\n\tvar err error\n\tvar cert tls.Certificate\n\tpemBlocks[\"cert\"], err = os.ReadFile(certpath)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to read certificate file: %v\", err)\n\t}\n\n\tpemBlocks[\"key\"], err = os.ReadFile(keypath)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to read key file: %v\", err)\n\t}\n\tcert, err = tls.X509KeyPair(pemBlocks[\"cert\"], pemBlocks[\"key\"])\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to parse certificate and key: %v\", err)\n\t}\n\n\t// Fetch the OCSP response and add it to the certificate\n\tocspResponseBytes, _, err := fetchOCSPResponse(cert)\n\tif err != nil {\n\t\tlog.Printf(\"Failed to fetch OCSP response: %v\", err)\n\t}\n\n\tcert.OCSPStaple = ocspResponseBytes\n\treturn []tls.Certificate{cert}, nil\n\n}\n\nfunc Serve(addr string) error {\n\tif addr == \"\" {\n\t\treturn errors.New(\"port is required\")\n\t}\n\n\tserver := \u0026http.Server{\n\t\tAddr: addr,\n\t\tHandler: serverHandler,\n\t}\n\tlog.Printf(\"Starting server on http://localhost%s\\n\", addr)\n\n\ttcpListener, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to listen on TCP: %v\", err)\n\t}\n\n\terrChan := make(chan error, 1)\n\tkill := make(chan bool, 1)\n\n\tgo func() {\n\n\t\t//logger.LogTLSListener(tlsListener)\n\t\tlog.Printf(\"Serving HTTP/1.1 and HTTP/2\")\n\t\terrChan \u003c- server.Serve(tcpListener)\n\n\t}()\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT)\n\tgo func() {\n\t\t\u003c-quit\n\n\t\tkill \u003c- true\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\n\t\thttp2Server.Shutdown(ctx)\n\t\thttp3Server.Close()\n\n\t\tlog.Print(\"Servers stopped gracefully\")\n\t}()\n\tselect {\n\tcase err := \u003c-errChan:\n\t\treturn fmt.Errorf(\"HTTP/1.1 and HTTP/2 server failed: %w\", err)\n\tcase \u003c-kill:\n\t\treturn nil\n\t}\n\n}\n"}]` diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1 @@ +package utils