From 52d1572ce40586396350d05f7121dda54401e06b Mon Sep 17 00:00:00 2001 From: euforic Date: Tue, 12 Sep 2023 04:52:21 +0000 Subject: [PATCH] first --- .github/workflows/go.yml | 75 +++++ LICENSE | 22 ++ README.md | 293 ++++++++++++++++++ dep.go | 142 +++++++++ dep_test.go | 103 ++++++ embed_func.go | 62 ++++ embed_func_test.go | 75 +++++ executor.go | 80 +++++ executor_test.go | 86 +++++ funcs.go | 45 +++ go.mod | 32 ++ go.sum | 144 +++++++++ import_func.go | 60 ++++ import_func_test.go | 59 ++++ string_funcs.go | 110 +++++++ string_funcs_test.go | 93 ++++++ templit.go | 111 +++++++ templit_test.go | 111 +++++++ .../basic_test/docs/details/nested.txt | 1 + test_data/outputs/basic_test/greeting.txt | 1 + test_data/outputs/basic_test/info.txt | 1 + test_data/templates/basic_test/-block.txt | 8 + .../basic_test/docs/details/nested.txt | 1 + test_data/templates/basic_test/greeting.txt | 1 + test_data/templates/basic_test/info.txt | 1 + ...{if .templatefile}}templatefile.txt{{end}} | 0 test_test.go | 137 ++++++++ 27 files changed, 1854 insertions(+) create mode 100644 .github/workflows/go.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dep.go create mode 100644 dep_test.go create mode 100644 embed_func.go create mode 100644 embed_func_test.go create mode 100644 executor.go create mode 100644 executor_test.go create mode 100644 funcs.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 import_func.go create mode 100644 import_func_test.go create mode 100644 string_funcs.go create mode 100644 string_funcs_test.go create mode 100644 templit.go create mode 100644 templit_test.go create mode 100644 test_data/outputs/basic_test/docs/details/nested.txt create mode 100644 test_data/outputs/basic_test/greeting.txt create mode 100644 test_data/outputs/basic_test/info.txt create mode 100644 test_data/templates/basic_test/-block.txt create mode 100644 test_data/templates/basic_test/docs/details/nested.txt create mode 100644 test_data/templates/basic_test/greeting.txt create mode 100644 test_data/templates/basic_test/info.txt create mode 100644 test_data/templates/basic_test/{{if .templatefile}}templatefile.txt{{end}} create mode 100644 test_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..7614095 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,75 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + pull-requests: read + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: go build -v ./... + + + + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: false + - name: Test + run: go test -v ./... + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + # Require: The version of golangci-lint to use. + # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. + # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. + version: v1.54 + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + # + # Note: By default, the `.golangci.yml` file should be at the root of the repository. + # The location of the configuration file can be changed by using `--config=` + # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 + + # Optional: show only new issues if it's a pull request. The default value is `false`. + # only-new-issues: true + + # Optional: if set to true, then all caching functionality will be completely disabled, + # takes precedence over all other caching options. + # skip-cache: true + + # Optional: if set to true, then the action won't cache or restore ~/go/pkg. + # skip-pkg-cache: true + + # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. + # skip-build-cache: true + + # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. + # install-mode: "goinstall" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2734d12 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2020 euforic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..daa9cf4 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# templit +[![Go Report Card](https://goreportcard.com/badge/github.com/euforic/templit)](https://goreportcard.com/report/github.com/euforic/templit) +[![GoDoc](https://godoc.org/github.com/euforic/templit?status.svg)](https://godoc.org/github.com/euforic/templit) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/euforic/templit) +![Build Status](https://github.com/euforic/templit/workflows/Go/badge.svg) +![GitHub](https://img.shields.io/github/license/euforic/templit) + +```go +import "github.com/euforic/templit" +``` + +## Index + +- [Variables](<#variables>) +- [func EmbedFunc\(client GitClient\) func\(remotePath string, data interface\{\}, funcMap template.FuncMap\) \(string, error\)](<#EmbedFunc>) +- [func ImportFunc\(client GitClient\) func\(repoAndTag, destPath string, data interface\{\}, funcMap template.FuncMap\) \(string, error\)](<#ImportFunc>) +- [func RenderTemplate\(tmpl string, data interface\{\}, funcMap template.FuncMap\) \(string, error\)](<#RenderTemplate>) +- [func ToCamelCase\(s string\) string](<#ToCamelCase>) +- [func ToKebabCase\(s string\) string](<#ToKebabCase>) +- [func ToPascalCase\(s string\) string](<#ToPascalCase>) +- [func ToSnakeCase\(s string\) string](<#ToSnakeCase>) +- [func WalkAndProcessDir\(inputDir, outputDir string, funcMap template.FuncMap, data interface\{\}\) error](<#WalkAndProcessDir>) +- [type DefaultGitClient](<#DefaultGitClient>) + - [func NewDefaultGitClient\(token string\) \*DefaultGitClient](<#NewDefaultGitClient>) + - [func \(d \*DefaultGitClient\) Checkout\(path, branch string\) error](<#DefaultGitClient.Checkout>) + - [func \(d \*DefaultGitClient\) Clone\(host, owner, repo, dest string\) error](<#DefaultGitClient.Clone>) +- [type DepInfo](<#DepInfo>) + - [func ParseDepURL\(rawURL string\) \(\*DepInfo, error\)](<#ParseDepURL>) +- [type Executor](<#Executor>) + - [func NewExecutor\(inputPath string, funcMap template.FuncMap\) \(\*Executor, error\)](<#NewExecutor>) + - [func \(e Executor\) Render\(name string, data interface\{\}\) \(string, error\)](<#Executor.Render>) +- [type GitClient](<#GitClient>) +- [type WalkAndProcessDirFunc](<#WalkAndProcessDirFunc>) + + +## Variables + +DefaultFuncMap is the default function map for templates. + +```go +var DefaultFuncMap = template.FuncMap{ + "lower": strings.ToLower, + "upper": strings.ToUpper, + "trim": strings.TrimSpace, + "split": strings.Split, + "join": strings.Join, + "replace": strings.ReplaceAll, + "contains": strings.Contains, + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "trimPrefix": strings.TrimPrefix, + "trimSuffix": strings.TrimSuffix, + "trimSpace": strings.TrimSpace, + "trimLeft": strings.TrimLeft, + "trimRight": strings.TrimRight, + "count": strings.Count, + "repeat": strings.Repeat, + "equalFold": strings.EqualFold, + "splitN": strings.SplitN, + "splitAfter": strings.SplitAfter, + "splitAfterN": strings.SplitAfterN, + "fields": strings.Fields, + "toTitle": strings.ToTitle, + "toSnakeCase": ToSnakeCase, + "toCamelCase": ToCamelCase, + "toKebabCase": ToKebabCase, + "toPascalCase": ToPascalCase, + "default": DefaultVal, +} +``` + + +## func EmbedFunc + +```go +func EmbedFunc(client GitClient) func(remotePath string, data interface{}, funcMap template.FuncMap) (string, error) +``` + +EmbedFunc returns a template function that can be used to process and embed a template from a remote git repository. EmbedFunc allows embedding content from a remote repository directly into a Go template. + +Steps to use: + +1. Add the function to the FuncMap. +2. Use the following syntax within your template: + ```go + {{ embed "///@" . }} + {{ embed "//#@" . }} + ``` + +Placeholders: + +- ``: Repository hosting service (e.g., "github.com"). +- ``: Repository owner or organization. +- ``: Repository name. +- ``: Path to the desired template file within the repository. +- ``: Specific template block name. +- ``: Specific Git reference (tag, commit hash, or branch name). + + +## func ImportFunc + +```go +func ImportFunc(client GitClient) func(repoAndTag, destPath string, data interface{}, funcMap template.FuncMap) (string, error) +``` + +ImportFunc returns a function that can be used as a template function to import and process a template from a remote git repository. ImportFunc allows embedding content from a remote repository into a Go template. + +Steps to use: + +1. Add the function to the FuncMap. +2. Use the following syntax within your template: + ```go + {{ import "///@" "" . }} + ``` + +Placeholders: + +- ``: Repository hosting service (e.g., "github.com"). +- ``: Repository owner or organization. +- ``: Repository name. +- ``: Path to the desired file or directory within the repository. +- ``: Specific Git reference (tag, commit hash, or branch name). + + + +## func RenderTemplate + +```go +func RenderTemplate(tmpl string, data interface{}, funcMap template.FuncMap) (string, error) +``` + +RenderTemplate renders a template with provided data. + + +## func ToCamelCase + +```go +func ToCamelCase(s string) string +``` + +ToCamelCase converts a string to CamelCase. + + +## func ToKebabCase + +```go +func ToKebabCase(s string) string +``` + +ToKebabCase converts a string to kebab\-case. + + +## func ToPascalCase + +```go +func ToPascalCase(s string) string +``` + +ToPascalCase converts a string to PascalCase. + + +## func ToSnakeCase + +```go +func ToSnakeCase(s string) string +``` + +ToSnakeCase converts a string to snake\_case. + + +## func WalkAndProcessDir + +```go +func WalkAndProcessDir(inputDir, outputDir string, funcMap template.FuncMap, data interface{}) error +``` + +WalkAndProcessDir processes all files in a directory with the given data. If walkFunc is provided, it's called for each file and directory without writing the file to disk. + + +## type DefaultGitClient + +DefaultGitClient provides a default implementation for the GitClient interface. + +```go +type DefaultGitClient struct { + Token string + BaseURL string +} +``` + + +### func NewDefaultGitClient + +```go +func NewDefaultGitClient(token string) *DefaultGitClient +``` + +NewDefaultGitClient creates a new DefaultGitClient with the given token. + + +### func \(\*DefaultGitClient\) Checkout + +```go +func (d *DefaultGitClient) Checkout(path, branch string) error +``` + +Checkout checks out a branch in a Git repository. + + +### func \(\*DefaultGitClient\) Clone + +```go +func (d *DefaultGitClient) Clone(host, owner, repo, dest string) error +``` + +Clone clones a Git repository to the given destination. + + +## type DepInfo + +DepInfo contains information about an embed URL. + +```go +type DepInfo struct { + Host string + Owner string + Repo string + Path string + Block string + Tag string +} +``` + + +### func ParseDepURL + +```go +func ParseDepURL(rawURL string) (*DepInfo, error) +``` + +ParseDepURL is a parsed embed URL. + + +## type Executor + +Executor is a wrapper around the template.Template type + +```go +type Executor struct { + *template.Template +} +``` + + +### func NewExecutor + +```go +func NewExecutor(inputPath string, funcMap template.FuncMap) (*Executor, error) +``` + +NewExecutor creates a new Executor with the given template and funcMap + + +### func \(Executor\) Render + +```go +func (e Executor) Render(name string, data interface{}) (string, error) +``` + +Render executes the template with the given data + + +## type GitClient + +GitClient is an interface that abstracts Git operations. + +```go +type GitClient interface { + Clone(host, owner, repo, dest string) error + Checkout(path, branch string) error +} +``` + + +## type WalkAndProcessDirFunc + +WalkAndProcessDirFunc is called for each file and directory when walking a directory. + +```go +type WalkAndProcessDirFunc func(path string, isDir bool, content string) error +``` + + diff --git a/dep.go b/dep.go new file mode 100644 index 0000000..c7bad7e --- /dev/null +++ b/dep.go @@ -0,0 +1,142 @@ +package templit + +import ( + "fmt" + "net/url" + "strings" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// DepInfo contains information about an embed URL. +type DepInfo struct { + Host string + Owner string + Repo string + Path string + Block string + Tag string +} + +// ParseDepURL is a parsed embed URL. +func ParseDepURL(rawURL string) (*DepInfo, error) { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + rawURL = "https://" + rawURL + } + + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + // Split path into components + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(pathParts) < 2 { + return nil, fmt.Errorf("invalid path format in embed URL") + } + + owner := pathParts[0] + repo := pathParts[1] + path := "" + if len(pathParts) > 2 { + path = strings.Join(pathParts[2:], "/") + } + + // Extract block and version + block, tag := extractBlockAndTag(u.Fragment) + + if tag == "" { + if strings.Contains(path, "@") { + path, tag = splitAtSign(path) + } else if strings.Contains(repo, "@") { + repo, tag = splitAtSign(repo) + } + } + + return &DepInfo{ + Host: u.Host, + Owner: owner, + Repo: repo, + Path: path, + Block: block, + Tag: tag, + }, nil +} + +// splitAtSign splits the given string at the '@' sign and returns both parts. +func splitAtSign(s string) (string, string) { + parts := strings.Split(s, "@") + if len(parts) > 1 { + return parts[0], parts[1] + } + return parts[0], "" +} + +// extractBlockAndTag extracts the block and tag from a fragment. +func extractBlockAndTag(fragment string) (string, string) { + parts := strings.Split(fragment, "@") + if len(parts) > 1 { + return parts[0], parts[1] + } else if len(parts) == 1 { + return parts[0], "" + } + return "", "" +} + +// GitClient is an interface that abstracts Git operations. +type GitClient interface { + Clone(host, owner, repo, dest string) error + Checkout(path, branch string) error +} + +// DefaultGitClient provides a default implementation for the GitClient interface. +type DefaultGitClient struct { + Token string + BaseURL string +} + +// NewDefaultGitClient creates a new DefaultGitClient with the given token. +func NewDefaultGitClient(token string) *DefaultGitClient { + return &DefaultGitClient{ + Token: token, + } +} + +// Clone clones a Git repository to the given destination. +func (d *DefaultGitClient) Clone(host, owner, repo, dest string) error { + repoURL := fmt.Sprintf("%s/%s/%s.git", host, owner, repo) + + var auth *http.BasicAuth + if d.Token != "" { + auth = &http.BasicAuth{ + Username: "username", // this can be anything except an empty string + Password: d.Token, + } + } + + _, err := git.PlainClone(dest, false, &git.CloneOptions{ + URL: repoURL, + Auth: auth, + }) + + return err +} + +// Checkout checks out a branch in a Git repository. +func (d *DefaultGitClient) Checkout(path, branch string) error { + r, err := git.PlainOpen(path) + if err != nil { + return err + } + + w, err := r.Worktree() + if err != nil { + return err + } + + return w.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(branch), + }) +} diff --git a/dep_test.go b/dep_test.go new file mode 100644 index 0000000..d03d364 --- /dev/null +++ b/dep_test.go @@ -0,0 +1,103 @@ +package templit + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestParseDepURL tests the ParseDepURL function. +func TestParseDepURL(t *testing.T) { + tests := []struct { + name string + rawURL string + expected *DepInfo + wantErr bool + }{ + { + name: "Basic URL with path and fragment", + rawURL: "https://github.com/owner/repo/path/to/file#block@v1.2.3", + expected: &DepInfo{ + Host: "github.com", + Owner: "owner", + Repo: "repo", + Path: "path/to/file", + Block: "block", + Tag: "v1.2.3", + }, + wantErr: false, + }, + { + name: "URL without path", + rawURL: "https://github.com/owner/repo", + expected: &DepInfo{ + Host: "github.com", + Owner: "owner", + Repo: "repo", + Path: "", + Block: "", + Tag: "", + }, + wantErr: false, + }, + { + name: "URL with tag in path", + rawURL: "https://github.com/owner/repo/path/to/file@v1.2.3", + expected: &DepInfo{ + Host: "github.com", + Owner: "owner", + Repo: "repo", + Path: "path/to/file", + Block: "", + Tag: "v1.2.3", + }, + wantErr: false, + }, + { + name: "URL with tag in repo", + rawURL: "https://github.com/owner/repo@v1.2.3", + expected: &DepInfo{ + Host: "github.com", + Owner: "owner", + Repo: "repo", + Path: "", + Block: "", + Tag: "v1.2.3", + }, + wantErr: false, + }, + { + name: "URL without protocol", + rawURL: "https://github.com/owner/repo/some/path#test_block@v1.2.3", + expected: &DepInfo{ + Host: "github.com", + Owner: "owner", + Repo: "repo", + Path: "some/path", + Block: "test_block", + Tag: "v1.2.3", + }, + wantErr: false, + }, + { + name: "Invalid URL missing repo name", + rawURL: "https://github.com/owner", + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDepURL(tt.rawURL) + if (err != nil) != tt.wantErr { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + if err == nil { + if diff := cmp.Diff(tt.expected, result); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + } + }) + } +} diff --git a/embed_func.go b/embed_func.go new file mode 100644 index 0000000..d54c655 --- /dev/null +++ b/embed_func.go @@ -0,0 +1,62 @@ +package templit + +import ( + "fmt" + "os" + "path" + "text/template" +) + +// EmbedFunc returns a template function that can be used to process and embed a template from a remote git repository. +// EmbedFunc allows embedding content from a remote repository directly into a Go template. +// +// Steps to use: +// 1. Add the function to the FuncMap. +// 2. Use the following syntax within your template: +// `{{ embed "///@" . }}` +// or +// `{{ embed "//#@" . }}` +// +// Placeholders: +// - ``: Repository hosting service (e.g., "github.com"). +// - ``: Repository owner or organization. +// - ``: Repository name. +// - ``: Path to the desired file or directory within the repository. +// - ``: Specific template block name. +// - ``: Specific Git reference (tag, commit hash, or branch name). +func EmbedFunc(client GitClient) func(remotePath string, data interface{}, funcMap template.FuncMap) (string, error) { + return func(remotePath string, data interface{}, funcMap template.FuncMap) (string, error) { + embedInfo, err := ParseDepURL(remotePath) + if err != nil { + return "", err + } + + const tempDirPrefix = "temp_clone_" + tempDir, err := os.MkdirTemp("", tempDirPrefix) + if err != nil { + return "", fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) // Cleanup + + err = client.Clone(embedInfo.Host, embedInfo.Owner, embedInfo.Repo, tempDir) + if err != nil { + return "", fmt.Errorf("failed to clone repo: %w", err) + } + + err = client.Checkout(tempDir, embedInfo.Tag) + if err != nil { + return "", fmt.Errorf("failed to checkout branch: %w", err) + } + + executor, err := NewExecutor(tempDir, funcMap) + if err != nil { + return "", fmt.Errorf("failed to create executor: %w", err) + } + + if embedInfo.Block != "" { + return executor.Render(embedInfo.Block, data) + } + + return executor.Render(path.Join(tempDir, embedInfo.Path), data) + } +} diff --git a/embed_func_test.go b/embed_func_test.go new file mode 100644 index 0000000..866641c --- /dev/null +++ b/embed_func_test.go @@ -0,0 +1,75 @@ +package templit + +import ( + "fmt" + "os" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestEmbedFunc tests the EmbedFunc function. +func TestEmbedFunc(t *testing.T) { + tests := []struct { + name string + repoAndPath string + ctx interface{} + expectedFile string + expectedText string + expectedError error + }{ + { + name: "Valid template fetch and execute", + repoAndPath: "https://test_data/templates/basic_test/greeting.txt@main", + ctx: map[string]string{"Name": "John"}, + expectedFile: "test_data/outputs/basic_test/greeting.txt", + }, + { + name: "Valid template fetch and execute block", + repoAndPath: "https://test_data/templates/basic_test/-block.txt#example_block@main", + ctx: map[string]string{"Greeting": "Hey"}, + expectedText: "Hey, this is an example block.", + }, + { + name: "Invalid repo path format", + repoAndPath: "invalidpath", + ctx: nil, + expectedText: "", + expectedError: fmt.Errorf("invalid path format in embed URL"), + }, + { + name: "Invalid repo", + repoAndPath: "https://localhost/owner/invalidrepo/greeting.txt@main", + ctx: nil, + expectedFile: "", + expectedError: fmt.Errorf("failed to clone repo: open localhost/owner/invalidrepo: no such file or directory"), + }, + } + + client := &MockGitClient{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fn := EmbedFunc(client) + result, err := fn(tt.repoAndPath, tt.ctx, nil) + if err != nil { + if tt.expectedError == nil || err.Error() != tt.expectedError.Error() { + t.Fatalf("expected error %v, got %v", tt.expectedError, err) + } + return + } + + var expected string + if tt.expectedFile != "" { + expectedBytes, _ := os.ReadFile(tt.expectedFile) + expected = string(expectedBytes) + } else { + expected = tt.expectedText + } + + if diff := cmp.Diff(string(expected), result); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/executor.go b/executor.go new file mode 100644 index 0000000..3e40770 --- /dev/null +++ b/executor.go @@ -0,0 +1,80 @@ +package templit + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Executor is a wrapper around the template.Template type +type Executor struct { + *template.Template +} + +// NewExecutor creates a new Executor with the given template and funcMap +func NewExecutor(inputPath string, funcMap template.FuncMap) (*Executor, error) { + if funcMap == nil { + funcMap = DefaultFuncMap + } + + mainTmpl := template.New("main").Funcs(funcMap) + + // check if input is a directory + info, err := os.Stat(inputPath) + if err != nil { + return nil, fmt.Errorf("failed to stat input: %w", err) + } + + if !info.IsDir() { + content, err := os.ReadFile(inputPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + tmpl, err := mainTmpl.New(inputPath).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + return &Executor{Template: tmpl}, nil + } + + err = filepath.Walk(inputPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("failed to walk directory: %w", err) + } + + if info.IsDir() { + return nil + } + + // Read, parse, and execute template only if it's a file + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + if _, err := mainTmpl.New(path).Parse(string(content)); err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + return &Executor{ + Template: mainTmpl, + }, nil +} + +// Render executes the template with the given data +func (e Executor) Render(name string, data interface{}) (string, error) { + var buf strings.Builder + if err := e.ExecuteTemplate(&buf, name, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + return buf.String(), nil +} diff --git a/executor_test.go b/executor_test.go new file mode 100644 index 0000000..4f416f4 --- /dev/null +++ b/executor_test.go @@ -0,0 +1,86 @@ +package templit + +import ( + "testing" + "text/template" + + "github.com/google/go-cmp/cmp" +) + +// TestNewExecutor tests the NewExecutor function. +func TestNewExecutor(t *testing.T) { + tests := []struct { + name string + input string + funcMap template.FuncMap + expected string + err bool + }{ + { + name: "valid template file", + input: "test_data/templates/basic_test/greeting.txt", + funcMap: nil, + err: false, + }, + // ... add more test cases here + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + executor, err := NewExecutor(tt.input, tt.funcMap) + if (err != nil) != tt.err { + t.Fatalf("expected error: %v, got: %v", tt.err, err) + } + if executor == nil { + t.Fatalf("expected executor to not be nil") + } + }) + } +} + +// TestRender tests the Render function. +func TestRender(t *testing.T) { + tests := []struct { + name string + inputPath string + templateName string + data interface{} + expected string + err bool + }{ + { + name: "valid template", + inputPath: "test_data/templates/basic_test", + templateName: "test_data/templates/basic_test/greeting.txt", + data: map[string]string{"Name": "John"}, + expected: "Hello, John!\n", + err: false, + }, + { + name: "valid template block", + inputPath: "test_data/templates/basic_test", + templateName: "example_block", + data: map[string]string{"greeting": "Hey"}, + expected: "Hey, this is an example block.", + err: false, + }, + + // ... add more test cases here + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + executor, err := NewExecutor(tt.inputPath, nil) + if err != nil { + t.Fatalf("failed to create executor: %v", err) + } + result, err := executor.Render(tt.templateName, tt.data) + if (err != nil) != tt.err { + t.Fatalf("expected error: %v, got: %v", tt.err, err) + } + if diff := cmp.Diff(tt.expected, result); diff != "" { + t.Fatalf("unexpected result (-want +got):\n%s", diff) + } + }) + } +} diff --git a/funcs.go b/funcs.go new file mode 100644 index 0000000..64efd8f --- /dev/null +++ b/funcs.go @@ -0,0 +1,45 @@ +package templit + +import ( + "strings" + "text/template" +) + +// DefaultFuncMap is the default function map for templates. +var DefaultFuncMap = template.FuncMap{ + "lower": strings.ToLower, + "upper": strings.ToUpper, + "trim": strings.TrimSpace, + "split": strings.Split, + "join": strings.Join, + "replace": strings.ReplaceAll, + "contains": strings.Contains, + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "trimPrefix": strings.TrimPrefix, + "trimSuffix": strings.TrimSuffix, + "trimSpace": strings.TrimSpace, + "trimLeft": strings.TrimLeft, + "trimRight": strings.TrimRight, + "count": strings.Count, + "repeat": strings.Repeat, + "equalFold": strings.EqualFold, + "splitN": strings.SplitN, + "splitAfter": strings.SplitAfter, + "splitAfterN": strings.SplitAfterN, + "fields": strings.Fields, + "toTitle": strings.ToTitle, + "toSnakeCase": ToSnakeCase, + "toCamelCase": ToCamelCase, + "toKebabCase": ToKebabCase, + "toPascalCase": ToPascalCase, + "default": defaultVal, +} + +// defaultVal returns defaultValue if value is nil, otherwise value. +func defaultVal(value, defaultValue interface{}) interface{} { + if value == nil { + return defaultValue + } + return value +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59d9486 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/euforic/templit + +go 1.21.0 + +require ( + github.com/go-git/go-git/v5 v5.8.1 + github.com/google/go-cmp v0.5.9 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.4.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect + github.com/skeema/knownhosts v1.2.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/tools v0.6.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8cc10aa --- /dev/null +++ b/go.sum @@ -0,0 +1,144 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs= +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= +github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= +github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= +github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A= +github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= +github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/import_func.go b/import_func.go new file mode 100644 index 0000000..f8424a1 --- /dev/null +++ b/import_func.go @@ -0,0 +1,60 @@ +package templit + +import ( + "fmt" + "os" + "path/filepath" + "text/template" +) + +// ImportFunc returns a function that can be used as a template function to import and process a template from a remote git repository. +// ImportFunc allows embedding content from a remote repository into a Go template. +// +// Steps to use: +// 1. Add the function to the FuncMap. +// 2. Use the following syntax within your template: +// `{{ import "///@" "" . }}` +// +// Placeholders: +// - ``: Repository hosting service (e.g., "github.com"). +// - ``: Repository owner or organization. +// - ``: Repository name. +// - ``: Path to the desired file or directory within the repository. +// - ``: Specific Git reference (tag, commit hash, or branch name). +func ImportFunc(client GitClient) func(repoAndTag, destPath string, data interface{}, funcMap template.FuncMap) (string, error) { + return func(repoAndTag, destPath string, data interface{}, funcMap template.FuncMap) (string, error) { + const tempDirPrefix = "temp_clone_" + + depInfo, err := ParseDepURL(repoAndTag) + if err != nil { + return "", fmt.Errorf("failed to parse embed URL: %w", err) + } + + if depInfo.Tag == "" { + depInfo.Tag = "main" + } + + tempDir, err := os.MkdirTemp("", tempDirPrefix) + if err != nil { + return "", fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) // Cleanup + + if err := client.Clone(depInfo.Host, depInfo.Owner, depInfo.Repo, tempDir); err != nil { + return "", fmt.Errorf("failed to clone repo: %w", err) + } + + err = client.Checkout(tempDir, depInfo.Tag) + if err != nil { + return "", fmt.Errorf("failed to checkout branch: %w", err) + } + + sourcePath := filepath.Join(tempDir, depInfo.Path) + err = WalkAndProcessDir(sourcePath, destPath, funcMap, data) + if err != nil { + return "", fmt.Errorf("failed to process template: %w", err) + } + + return "", nil + } +} diff --git a/import_func_test.go b/import_func_test.go new file mode 100644 index 0000000..cbdbbcb --- /dev/null +++ b/import_func_test.go @@ -0,0 +1,59 @@ +package templit + +import ( + "fmt" + "os" + "strings" + "testing" +) + +// TestImportFunc tests the ImportFunc function. +func TestImportFunc(t *testing.T) { + client := &MockGitClient{} + + tests := []struct { + name string + repoAndTag string + data interface{} + expectedError error + }{ + { + // Add your test cases here + name: "Valid template processing", + repoAndTag: "https://test_data/templates/basic_test@main", + data: map[string]interface{}{ + "Name": "John", + "Title": "Project", + "Description": "This is a test project.", + "Detail": "more info here.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + destPath, err := os.MkdirTemp("", "test_output_"+tt.name) + if err != nil { + t.Fatal(fmt.Errorf("failed to create temp dir: %w", err)) + } + defer os.RemoveAll(destPath) // Cleanup + + fn := ImportFunc(client) + if _, err := fn(tt.repoAndTag, destPath, tt.data, nil); err != nil { + if tt.expectedError == nil || err.Error() != tt.expectedError.Error() { + t.Fatalf("expected error %v, got %v", tt.expectedError, err) + } + return + } + + // Compare generated files with expected outputs + repo := strings.Split(tt.repoAndTag, "@")[0] + expectedOutputPath := strings.Replace(repo, "templates", "outputs", 1) + + err = compareDirs(destPath, strings.TrimPrefix(expectedOutputPath, "https://")) + if err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/string_funcs.go b/string_funcs.go new file mode 100644 index 0000000..f2eb27e --- /dev/null +++ b/string_funcs.go @@ -0,0 +1,110 @@ +package templit + +import ( + "strings" + "unicode" +) + +// ToCamelCase converts a string to CamelCase. +func ToCamelCase(s string) string { + parts := splitAndFilter(s) + for i, part := range parts { + if i == 0 { + parts[i] = strings.ToLower(part) + } else { + parts[i] = capitalizeFirstLetter(part) + } + } + return strings.Join(parts, "") +} + +// ToSnakeCase converts a string to snake_case. +func ToSnakeCase(s string) string { + var result strings.Builder + previousIsLower := false + + for _, r := range s { + switch { + case unicode.IsUpper(r) && previousIsLower: + result.WriteRune('_') + result.WriteRune(unicode.ToLower(r)) + previousIsLower = false + case r == ' ', r == '-', r == '_': + result.WriteRune('_') + previousIsLower = false + case unicode.IsLower(r) || unicode.IsDigit(r): + result.WriteRune(r) + previousIsLower = true + default: + result.WriteRune(unicode.ToLower(r)) + previousIsLower = false + } + } + return result.String() +} + +// ToKebabCase converts a string to kebab-case. +func ToKebabCase(s string) string { + var result strings.Builder + previousIsLower := false + + for _, r := range s { + switch { + case unicode.IsUpper(r) && previousIsLower: + result.WriteRune('-') + result.WriteRune(unicode.ToLower(r)) + previousIsLower = false + case r == ' ', r == '_', r == '-': + result.WriteRune('-') + previousIsLower = false + case unicode.IsLower(r) || unicode.IsDigit(r): + result.WriteRune(r) + previousIsLower = true + default: + result.WriteRune(unicode.ToLower(r)) + previousIsLower = false + } + } + return result.String() +} + +// ToPascalCase converts a string to PascalCase. +func ToPascalCase(s string) string { + parts := splitAndFilter(s) + for i, part := range parts { + parts[i] = capitalizeFirstLetter(part) + } + return strings.Join(parts, "") +} + +// splitAndFilter splits a string by multiple delimiters and filters out non-alphanumeric characters. +func splitAndFilter(s string) []string { + parts := splitByMultipleDelimiters(s, []string{" ", "_", "-"}) + for i, part := range parts { + runes := []rune(part) + filtered := make([]rune, 0, len(runes)) + for _, r := range runes { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + filtered = append(filtered, r) + } + } + parts[i] = string(filtered) + } + return parts +} + +// splitByMultipleDelimiters splits a string based on multiple delimiters. +func splitByMultipleDelimiters(s string, delimiters []string) []string { + for _, delimiter := range delimiters { + s = strings.ReplaceAll(s, delimiter, " ") + } + return strings.Fields(s) +} + +// capitalizeFirstLetter capitalizes the first letter of a string. +func capitalizeFirstLetter(s string) string { + if s == "" { + return s + } + return strings.ToUpper(string(s[0])) + strings.ToLower(s[1:]) +} diff --git a/string_funcs_test.go b/string_funcs_test.go new file mode 100644 index 0000000..dcfdc77 --- /dev/null +++ b/string_funcs_test.go @@ -0,0 +1,93 @@ +package templit + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestToCamelCase tests the ToCamelCase function. +func TestToCamelCase(t *testing.T) { + tests := []struct { + input string + output string + }{ + {"hello world", "helloWorld"}, + {"Hello World", "helloWorld"}, + {"HELLO_WORLD", "helloWorld"}, + {"XML HTTP_request2_a-b", "xmlHttpRequest2AB"}, + } + + for _, test := range tests { + result := ToCamelCase(test.input) + if diff := cmp.Diff(test.output, result); diff != "" { + t.Errorf("toCamelCase(%s) mismatch (-want +got):\n%s", test.input, diff) + } + } +} + +// TestToSnakeCase tests the ToSnakeCase function. +func TestToSnakeCase(t *testing.T) { + tests := []struct { + input string + output string + }{ + {"hello world", "hello_world"}, + {"Hello World", "hello_world"}, + {"HELLO_WORLD", "hello_world"}, + {"HelloWorld", "hello_world"}, + {"HelloWorld Today", "hello_world_today"}, + {"HelloWorld-today", "hello_world_today"}, + {"XML HTTP_request2_a-b", "xml_http_request2_a_b"}, + } + + for _, test := range tests { + result := ToSnakeCase(test.input) + if diff := cmp.Diff(test.output, result); diff != "" { + t.Errorf("ToSnakeCase(%s) mismatch (-want +got):\n%s", test.input, diff) + } + } +} + +// TestToKebabCase tests the ToKebabCase function. +func TestToKebabCase(t *testing.T) { + tests := []struct { + input string + output string + }{ + {"hello world", "hello-world"}, + {"Hello World", "hello-world"}, + {"HELLO_WORLD", "hello-world"}, + {"HelloWorld", "hello-world"}, + {"HelloWorld Today", "hello-world-today"}, + {"HelloWorld-today", "hello-world-today"}, + {"XML HTTP_request2_a-b", "xml-http-request2-a-b"}, + } + + for _, test := range tests { + result := ToKebabCase(test.input) + if diff := cmp.Diff(test.output, result); diff != "" { + t.Errorf("ToKebabCase(%s) mismatch (-want +got):\n%s", test.input, diff) + } + } +} + +// TestToPascalCase tests the ToPascalCase function. +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + output string + }{ + {"hello world", "HelloWorld"}, + {"Hello World", "HelloWorld"}, + {"HELLO_WORLD", "HelloWorld"}, + {"XML HTTP_request2_a-b", "XmlHttpRequest2AB"}, + } + + for _, test := range tests { + result := ToPascalCase(test.input) + if diff := cmp.Diff(test.output, result); diff != "" { + t.Errorf("ToPascalCase(%s) mismatch (-want +got):\n%s", test.input, diff) + } + } +} diff --git a/templit.go b/templit.go new file mode 100644 index 0000000..7a6901a --- /dev/null +++ b/templit.go @@ -0,0 +1,111 @@ +package templit + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// WalkAndProcessDirFunc is called for each file and directory when walking a directory. +type WalkAndProcessDirFunc func(path string, isDir bool, content string) error + +// WalkAndProcessDir processes all files in a directory with the given data. +// If walkFunc is provided, it's called for each file and directory without writing the file to disk. +func WalkAndProcessDir(inputDir, outputDir string, funcMap template.FuncMap, data interface{}) error { + // Create output directory + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Master template for file processing + masterTmpl := template.New("main").Funcs(funcMap) + + err := filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error walking through directory: %w", err) + } + + parsedName, err := RenderTemplate(filepath.Base(path), data, funcMap) + if err != nil { + return fmt.Errorf("error rendering path template: %w", err) + } + + relPath, err := filepath.Rel(inputDir, filepath.Dir(path)) + if err != nil { + return fmt.Errorf("error getting relative path: %w", err) + } + + outPath := filepath.Join(outputDir, relPath, parsedName) + parsedOutPath, err := RenderTemplate(outPath, data, funcMap) + if err != nil { + return fmt.Errorf("error rendering path template: %w", err) + } + + if info.IsDir() { + // Skip directories with empty or "-" prefixed names + if parsedName == "" || strings.HasPrefix(parsedName, "-") { + return filepath.SkipDir + } + + // Skip root directory + if filepath.Base(outPath) == filepath.Base(inputDir) { + return nil + } + + if err := os.MkdirAll(parsedOutPath, info.Mode()); err != nil { + return fmt.Errorf("error creating directory: %w", err) + } + + return nil + } + + // Skip files with empty names or "-" prefixed + if parsedName == "" || strings.HasPrefix(parsedName, "-") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("error reading file from templates: %w", err) + } + + tmpl, err := masterTmpl.New(path).Parse(string(content)) + if err != nil { + return fmt.Errorf("error parsing template: %w", err) + } + + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("error executing template: %w", err) + } + + if err := os.WriteFile(parsedOutPath, []byte(buf.String()), info.Mode()); err != nil { + return fmt.Errorf("error writing file to output: %w", err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("error walking through directory: %w", err) + } + + return nil +} + +// RenderTemplate renders a template with provided data. +func RenderTemplate(tmpl string, data interface{}, funcMap template.FuncMap) (string, error) { + t, err := template.New("main").Funcs(funcMap).Parse(tmpl) + if err != nil { + return "", fmt.Errorf("error parsing template: %w", err) + } + + var buf strings.Builder + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("error executing template: %w", err) + } + + return buf.String(), nil +} diff --git a/templit_test.go b/templit_test.go new file mode 100644 index 0000000..b16a168 --- /dev/null +++ b/templit_test.go @@ -0,0 +1,111 @@ +package templit + +import ( + "fmt" + "os" + "strings" + "testing" + "text/template" +) + +// TestRenderTemplate tests the RenderTemplate function. +func TestRenderTemplate(t *testing.T) { + var tests = []struct { + name string + tmpl string + data interface{} + funcMap template.FuncMap + expected string + wantErr bool + }{ + { + name: "Simple template without data", + tmpl: "Hello, World!", + expected: "Hello, World!", + }, + { + name: "Template with data", + tmpl: "Hello, {{.Name}}!", + data: map[string]string{"Name": "John"}, + expected: "Hello, John!", + }, + { + name: "Template with function", + tmpl: "Hello, {{lower .Name}}!", + data: map[string]string{"Name": "JOHN"}, + funcMap: template.FuncMap{"lower": func(s string) string { return strings.ToLower(s) }}, + expected: "Hello, john!", + }, + { + name: "Malformed template", + tmpl: "Hello, {{.Name!", + data: map[string]string{"Name": "John"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RenderTemplate(tt.tmpl, tt.data, tt.funcMap) + if (err != nil) != tt.wantErr { + t.Fatalf("RenderTemplate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.expected { + t.Errorf("RenderTemplate() = %v, expected %v", got, tt.expected) + } + }) + } +} + +// TestWalkAndProcessDir tests the WalkAndProcessDir function. +func TestWalkAndProcessDir(t *testing.T) { + tests := []struct { + name string + inputDir string + data interface{} + funcMap template.FuncMap + expectedOutput string + expectedError error + }{ + { + name: "Simple directory processing", + inputDir: "test_data/templates/basic_test", + data: map[string]interface{}{ + "Name": "John", + "Title": "Project", + "Description": "This is a test project.", + "Detail": "more info here.", + }, + funcMap: DefaultFuncMap, + expectedOutput: "test_data/outputs/basic_test/", + }, + // ... (other test cases) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp directory for output + tempOutputDir, err := os.MkdirTemp("", "test_output_"+strings.ReplaceAll(strings.ToLower(tt.name), " ", "_")) + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + //defer os.RemoveAll(tempOutputDir) // Cleanup + + err = WalkAndProcessDir(tt.inputDir, tempOutputDir, tt.funcMap, tt.data) + if err != nil { + if tt.expectedError == nil || err.Error() != tt.expectedError.Error() { + t.Fatalf("expected error %v, got %v", tt.expectedError, err) + } + return + } + + // Compare generated output directory with expected directory + fmt.Println(tt.expectedOutput, tempOutputDir) + err = compareDirs(tt.expectedOutput, tempOutputDir) + if err != nil { + t.Fatalf("output directory mismatch: %v", err) + } + }) + } +} diff --git a/test_data/outputs/basic_test/docs/details/nested.txt b/test_data/outputs/basic_test/docs/details/nested.txt new file mode 100644 index 0000000..7616507 --- /dev/null +++ b/test_data/outputs/basic_test/docs/details/nested.txt @@ -0,0 +1 @@ +Nested more info here. diff --git a/test_data/outputs/basic_test/greeting.txt b/test_data/outputs/basic_test/greeting.txt new file mode 100644 index 0000000..1590ee0 --- /dev/null +++ b/test_data/outputs/basic_test/greeting.txt @@ -0,0 +1 @@ +Hello, John! diff --git a/test_data/outputs/basic_test/info.txt b/test_data/outputs/basic_test/info.txt new file mode 100644 index 0000000..8b649c1 --- /dev/null +++ b/test_data/outputs/basic_test/info.txt @@ -0,0 +1 @@ +Project: This is a test project. diff --git a/test_data/templates/basic_test/-block.txt b/test_data/templates/basic_test/-block.txt new file mode 100644 index 0000000..adeac3f --- /dev/null +++ b/test_data/templates/basic_test/-block.txt @@ -0,0 +1,8 @@ +{{ define "example_block" -}} +Hey, this is an example block. +{{- end -}} + +{{- define "other_block" -}} +Hey, this is an example block. +{{- end -}} + diff --git a/test_data/templates/basic_test/docs/details/nested.txt b/test_data/templates/basic_test/docs/details/nested.txt new file mode 100644 index 0000000..9f1642d --- /dev/null +++ b/test_data/templates/basic_test/docs/details/nested.txt @@ -0,0 +1 @@ +Nested {{.Detail}} diff --git a/test_data/templates/basic_test/greeting.txt b/test_data/templates/basic_test/greeting.txt new file mode 100644 index 0000000..89dce33 --- /dev/null +++ b/test_data/templates/basic_test/greeting.txt @@ -0,0 +1 @@ +Hello, {{.Name}}! diff --git a/test_data/templates/basic_test/info.txt b/test_data/templates/basic_test/info.txt new file mode 100644 index 0000000..3514fee --- /dev/null +++ b/test_data/templates/basic_test/info.txt @@ -0,0 +1 @@ +{{.Title}}: {{.Description}} diff --git a/test_data/templates/basic_test/{{if .templatefile}}templatefile.txt{{end}} b/test_data/templates/basic_test/{{if .templatefile}}templatefile.txt{{end}} new file mode 100644 index 0000000..e69de29 diff --git a/test_test.go b/test_test.go new file mode 100644 index 0000000..7a8a603 --- /dev/null +++ b/test_test.go @@ -0,0 +1,137 @@ +package templit + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/go-cmp/cmp" +) + +type MockGitClient struct{} + +func (m *MockGitClient) Clone(host, owner, repo, dest string) error { + src := filepath.Join(host, owner, repo) + return copyDir(src, dest) +} + +func (m *MockGitClient) Checkout(path, branch string) error { + // Mocked function, doesn't need to do anything for this test. + return nil +} + +// copyDir copies a directory recursively +func copyDir(src string, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + sourcePath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + err = os.MkdirAll(destPath, os.ModePerm) + if err != nil { + return err + } + err = copyDir(sourcePath, destPath) + if err != nil { + return err + } + continue + } + + if err := copyFile(sourcePath, destPath); err != nil { + return err + } + } + return nil +} + +// copyFile copies a file +func copyFile(src, dst string) error { + bytes, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, bytes, 0644) +} + +// compareDirs recursively compares the contents of two directories. +// dir1 is the expected directory, while dir2 is the actual directory. +func compareDirs(dir1, dir2 string) error { + entries1, err := os.ReadDir(dir1) + if err != nil { + return err + } + + entries2, err := os.ReadDir(dir2) + if err != nil { + return err + } + + entryMap2 := make(map[string]os.DirEntry) + for _, entry := range entries2 { + entryMap2[entry.Name()] = entry + } + + for _, entry1 := range entries1 { + entry2, exists := entryMap2[entry1.Name()] + + if !exists && strings.HasPrefix(entry1.Name(), "-") { + continue + } + + if !exists { + entryType := "file" + if entry1.IsDir() { + entryType = "directory" + } + + return fmt.Errorf("missing %s in %s: %s", entryType, dir2, entry1.Name()) + } + + if entry1.IsDir() { + if !entry2.IsDir() { + return fmt.Errorf("expected file, but found directory in %s: %s", dir2, entry1.Name()) + } + + if err := compareDirs(filepath.Join(dir1, entry1.Name()), filepath.Join(dir2, entry2.Name())); err != nil { + return err + } + } + + if entry1.Type().IsRegular() && entry2.Type().IsRegular() { + bytes1, err := os.ReadFile(filepath.Join(dir1, entry1.Name())) + if err != nil { + return err + } + + bytes2, err := os.ReadFile(filepath.Join(dir2, entry2.Name())) + if err != nil { + return err + } + + if diff := cmp.Diff(string(bytes1), string(bytes2)); diff != "" { + return fmt.Errorf("mismatch in files %s vs %s\n\t(-want +got):\n%s ", filepath.Join(dir1, entry1.Name()), filepath.Join(dir2, entry2.Name()), diff) + } + continue + } + + if entry1.Type().IsRegular() || entry2.Type().IsRegular() { + return fmt.Errorf("file type mismatch for %s in %s vs %s", entry1.Name(), dir1, dir2) + } + } + + // Check for any extra entries in dir2 that are not present in dir1 + for _, entry2 := range entries2 { + if _, exists := entryMap2[entry2.Name()]; !exists { + return fmt.Errorf("unexpected entry in %s: %s", dir2, entry2.Name()) + } + } + + return nil +}