diff --git a/cmd/cli.go b/cmd/cli.go new file mode 100644 index 0000000..9abbbd9 --- /dev/null +++ b/cmd/cli.go @@ -0,0 +1,86 @@ +package cmd + +// CLI holds all parsed command-line flags and positional arguments. +type CLI struct { + // operations + + // Page holds the positional arguments (page names to look up). + Page []string + + // Update requests a cache update. + Update bool + + // List requests listing pages for the current platform. + List bool + + // ListAll requests listing all pages across all platforms. + ListAll bool + + // Search requests a keyword search across pages. + Search string + + // ListPlatforms requests listing available platforms. + ListPlatforms bool + + // ListLanguages requests listing installed languages. + ListLanguages bool + + // Info requests showing cache information. + Info bool + + // Render requests rendering a local markdown file. + Render string + + // CleanCache requests interactively cleaning the cache. + CleanCache bool + + // GenConfig requests printing the default configuration. + GenConfig bool + + // ConfigPath requests printing the config file path. + ConfigPath bool + + // ShowVersion requests printing the version string. + ShowVersion bool + + // ShowHelp requests printing the help text. + ShowHelp bool + + // Options + + // Platform overrides the platform used for page lookup. + Platform string + + // Languages overrides the language list. + Languages []string + + // ShortOptions requests displaying short option forms. + ShortOptions bool + + // LongOptions requests displaying long option forms. + LongOptions bool + + // Edit requests displaying a GitHub edit link. + Edit bool + + // Offline suppresses automatic cache updates. + Offline bool + + // Compact strips empty lines from output. + Compact bool + + // Raw prints pages in raw markdown. + Raw bool + + // Quiet suppresses informational and warning messages. + Quiet bool + + // Verbose controls the verbosity level (0–2). + Verbose uint8 + + // Color controls when to enable color output: auto, always, never. + Color string + + // Config specifies an alternative config file path. + Config string +} diff --git a/cmd/count_value.go b/cmd/count_value.go new file mode 100644 index 0000000..6a60da0 --- /dev/null +++ b/cmd/count_value.go @@ -0,0 +1,31 @@ +package cmd + +import "fmt" + +// countValue implements flag.Value for a counter. +// +// Each occurrence of the associated flag increments the counter by one, +// allowing flags such as: +// +// --verbose +// --verbose --verbose +// +// to be represented as increasing verbosity levels. +type countValue struct { + count *uint8 +} + +// String returns the current counter value as a string. +func (v *countValue) String() string { + if v.count == nil { + return "0" + } + + return fmt.Sprintf("%d", *v.count) +} + +// Set increments the counter each time the flag is encountered. +func (v *countValue) Set(string) error { + *v.count++ + return nil +} diff --git a/cmd/list_values.go b/cmd/list_values.go new file mode 100644 index 0000000..c1fd3d0 --- /dev/null +++ b/cmd/list_values.go @@ -0,0 +1,30 @@ +package cmd + +import "strings" + +// stringListValue implements flag. +// Value for a string slice. +// It supports both repeated flags (-L de -L pl) +// and comma-separated values (-L de,pl). +type stringListValue struct { + values *[]string +} + +// String returns the comma-separated representation of the value. +func (v *stringListValue) String() string { + if v.values == nil { + return "" + } + return strings.Join(*v.values, ",") +} + +// Set appends one or more comma-separated values to the slice +func (v *stringListValue) Set(s string) error { + for part := range strings.SplitSeq(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + *v.values = append(*v.values, part) + } + } + return nil +} diff --git a/cmd/parse.go b/cmd/parse.go new file mode 100644 index 0000000..87cff43 --- /dev/null +++ b/cmd/parse.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + + "github.com/TheRootDaemon/tlgc/version" +) + +// Parse parses the process command-line arguments into a CLI value. +func Parse() (*CLI, error) { + return parse(os.Args[1:]) +} + +// parse parses the provided command-line arguments into a CLI value. +// +// It validates the parsed flags +// and ensures that exactly one operation has been requested. +// If the arguments are empty it prints help message. +func parse(args []string) (*CLI, error) { + cli := &CLI{} + + fs := flag.NewFlagSet("tlgc", flag.ContinueOnError) + + // operations + fs.BoolVar(&cli.Update, "u", false, "update the cache") + fs.BoolVar(&cli.Update, "update", false, "update the cache") + + fs.BoolVar( + &cli.List, + "l", + false, + "list all pages for the current platform", + ) + fs.BoolVar( + &cli.List, + "list", + false, + "list all pages for the current platform", + ) + + fs.BoolVar(&cli.ListAll, "a", false, "list all pages") + fs.BoolVar(&cli.ListAll, "list-all", false, "list all pages") + + fs.StringVar( + &cli.Search, + "s", + "", + "search for pages containing a keyword", + ) + fs.StringVar( + &cli.Search, + "search", + "", + "search for pages containing a keyword", + ) + + fs.BoolVar( + &cli.ListPlatforms, + "list-platforms", + false, + "list available platforms", + ) + + fs.BoolVar( + &cli.ListLanguages, + "list-languages", + false, + "list installed languages", + ) + + fs.BoolVar(&cli.Info, "i", false, "show cache information") + fs.BoolVar(&cli.Info, "info", false, "show cache information") + + fs.StringVar(&cli.Render, "r", "", "render the specified tldr page") + fs.StringVar(&cli.Render, "render", "", "render the specified tldr page") + + fs.BoolVar( + &cli.CleanCache, + "clean-cache", + false, + "interactively delete the cache contents", + ) + + fs.BoolVar( + &cli.GenConfig, + "gen-config", + false, + "print the default configuration", + ) + + fs.BoolVar( + &cli.ConfigPath, + "config-path", + false, + "print the configuration path", + ) + + fs.BoolVar(&cli.ShowVersion, "v", false, "display version") + fs.BoolVar(&cli.ShowVersion, "version", false, "display version") + + fs.BoolVar(&cli.ShowHelp, "h", false, "display help") + fs.BoolVar(&cli.ShowHelp, "help", false, "display help") + + // options + fs.StringVar( + &cli.Platform, + "p", + "", + "specify the platform to use (linux, osx, windows, etc.)", + ) + fs.StringVar( + &cli.Platform, + "platform", + "", + "specify the platform to use (linux, osx, windows, etc.)", + ) + + fs.Var( + &stringListValue{ + values: &cli.Languages, + }, + "L", + "specify the languages to use", + ) + fs.Var( + &stringListValue{ + values: &cli.Languages, + }, + "language", + "specify the languages to use", + ) + + fs.BoolVar( + &cli.ShortOptions, + "short-options", + false, + "display short options wherever possible (e.g. '-s')", + ) + fs.BoolVar( + &cli.LongOptions, + "long-options", + false, + "display long options wherever possible (e.g. '--long')", + ) + + fs.BoolVar( + &cli.Edit, + "edit", + false, + "display a link to edit the page on GitHub", + ) + + fs.BoolVar( + &cli.Offline, + "o", + false, + "do not update the cache, even if it is stale", + ) + fs.BoolVar( + &cli.Offline, + "offline", + false, + "do not update the cache, even if it is stale", + ) + + fs.BoolVar(&cli.Compact, "c", false, "strip empty lines from output") + fs.BoolVar(&cli.Compact, "compact", false, "strip empty lines from output") + + fs.BoolVar( + &cli.Raw, + "R", + false, + "print pages in raw markdown instead of rendering them", + ) + fs.BoolVar( + &cli.Raw, + "raw", + false, + "print pages in raw markdown instead of rendering them", + ) + + fs.BoolVar(&cli.Quiet, "q", false, "suppress status messages and warnings") + fs.BoolVar(&cli.Quiet, "quiet", false, "suppress status messages and warnings") + + fs.Var( + &countValue{ + count: &cli.Verbose, + }, + "verbose", + "increase verbosity (repeat upto twice)", + ) + + fs.StringVar( + &cli.Color, + "color", + "auto", + "specify when to enable color (auto, always, never)", + ) + + fs.StringVar( + &cli.Config, + "config", + "", + "specify an alternative configuration file", + ) + + if err := fs.Parse(args); err != nil { + return nil, err + } + + switch cli.Color { + case "auto", "always", "never": + default: + return nil, fmt.Errorf("invalid value %q for --color (expected auto, always, never)", cli.Color) + } + + // show version + if cli.ShowVersion { + fmt.Printf( + "tlgc %s (implementing client specification v2.3)\n", + version.Version, + ) + return cli, nil + } + + // show help + if cli.ShowHelp { + fmt.Println("TODO") + return cli, nil + } + + // positional arguments + cli.Page = fs.Args() + + // validate that exactly one operation is active + ops := cli.operationCount() + if ops == 0 { + fmt.Println("TODO") + return cli, nil + } else if ops > 1 { + return nil, fmt.Errorf("only one operation can be specified at a time") + } + + return cli, nil +} + +// operationCount returns how many operation-group flags are active. +func (c *CLI) operationCount() int { + count := 0 + + if len(c.Page) > 0 { + count++ + } + if c.Update { + count++ + } + if c.List { + count++ + } + if c.ListAll { + count++ + } + if c.Search != "" { + count++ + } + if c.ListPlatforms { + count++ + } + if c.ListLanguages { + count++ + } + if c.Info { + count++ + } + if c.Render != "" { + count++ + } + if c.CleanCache { + count++ + } + if c.GenConfig { + count++ + } + if c.ConfigPath { + count++ + } + + return count +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..ea4a80c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,126 @@ +package app + +import ( + "io" + "os" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/locale" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/platform" +) + +// App is the main application struct that holds I/O streams and configuration. +type App struct { + // Stdout is the writer for standard output. + Stdout io.Writer + + // Stderr is the writer for diagnostic output. + Stderr io.Writer + + // ConfigPath is the path to the configuration file, if set. + ConfigPath string +} + +// Option configures the App. +type Option func(*App) + +// WithStdout sets the standard output writer for the App. +func WithStdout(w io.Writer) Option { + return func(a *App) { + a.Stdout = w + } +} + +// WithStderr sets the standard error writer for the App. +func WithStderr(w io.Writer) Option { + return func(a *App) { + a.Stderr = w + } +} + +// WithConfigPath sets the configuration file path for the App. +func WithConfigPath(path string) Option { + return func(a *App) { + a.ConfigPath = path + } +} + +// New creates a new App with the given options. +// It defaults Stdout to os.Stdout and Stderr to os.Stderr. +func New(opts ...Option) *App { + a := &App{ + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + for _, opt := range opts { + opt(a) + } + return a +} + +// Run dispatches the CLI command to the appropriate handler. +// It initializes the config if needed, then delegates to the matching +// sub-handler based on the CLI flags. Returns 0 on success, 1 on error. +func (a *App) Run(cli *cmd.CLI) int { + needsConfig := !cli.GenConfig && !cli.ConfigPath && !cli.ShowVersion && !cli.ShowHelp + + if needsConfig { + if err := config.Initialize(); err != nil { + logger.Error("failed to load config: %v", err) + return 1 + } + } + + switch { + case cli.Update: + return a.updateCache(cli) + case cli.List: + return a.listPages(cli) + case cli.ListAll: + return a.listAllPages() + case cli.Search != "": + return a.searchPages(cli) + case cli.ListPlatforms: + return a.listPlatforms() + case cli.ListLanguages: + return a.listLanguages() + case cli.Info: + return a.cacheInfo() + case cli.Render != "": + return a.renderLocalFile(cli) + case cli.GenConfig: + return a.genConfig() + case cli.ConfigPath: + return a.configPath() + case len(cli.Page) > 0: + return a.lookupAndRenderPage(cli) + default: + return 0 + } +} + +// resolveLanguages returns the resolved list of languages from the CLI flag, +// config file, or system locale, in that order of precedence. +func (a *App) resolveLanguages(flagLangs []string) []string { + if len(flagLangs) > 0 { + return flagLangs + } + if cfgLangs := config.Cache().Languages; len(cfgLangs) > 0 { + return cfgLangs + } + var langs []string + locale.GetLanguages(&langs) + return langs +} + +// resolvePlatform returns the resolved platform string from the CLI flag +// or the default platform. +func (a *App) resolvePlatform(flagPlatform string) string { + if flagPlatform != "" { + return platform.Resolve(flagPlatform) + } + return platform.Default() +} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..57b9019 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,34 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" +) + +// genConfig prints a default configuration file to stdout. +// Returns 0 on success, 1 on error. +func (a *App) genConfig() int { + cfg, err := config.DefaultConfig() + if err != nil { + logger.Error("failed to generate config: %w", err) + return 1 + } + + if _, err := fmt.Fprint(a.Stdout, cfg); err != nil { + logger.Error("%w", err) + return 1 + } + return 0 +} + +// configPath prints the configuration file path to stdout. +// Returns 0 on success. +func (a *App) configPath() int { + if _, err := fmt.Fprintln(a.Stdout, config.ConfigPath()); err != nil { + logger.Error("%w", err) + return 1 + } + return 0 +} diff --git a/internal/app/doc.go b/internal/app/doc.go new file mode 100644 index 0000000..b2dc58a --- /dev/null +++ b/internal/app/doc.go @@ -0,0 +1,3 @@ +// Package app coordinates the cache, config, render, and upstream +// packages to provide the application's main entry point. +package app diff --git a/internal/app/info.go b/internal/app/info.go new file mode 100644 index 0000000..d4b4703 --- /dev/null +++ b/internal/app/info.go @@ -0,0 +1,50 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// cacheInfo prints cache metadata (directory, age, page count, etc.). +// Returns 0 on success, 1 on error. +func (a *App) cacheInfo() int { + c := cache.New() + info, err := c.Info() + if err != nil { + logger.Error("failed to get cache info: %v", err) + return 1 + } + + if _, err := fmt.Fprintf( + a.Stdout, + `Cache: %s +Cache age: %s +Total pages: %d +Auto update: %v +Max age (hours): %d +`, + info.CacheDir, + info.Age, + info.TotalPages, + info.AutoUpdate, + info.MaxAge, + ); err != nil { + logger.Error("%w", err) + return 1 + } + + for _, ls := range info.LanguageStats { + if _, err := fmt.Fprintf( + a.Stdout, + "%s: %d pages\n", + ls.Language, + ls.Pages, + ); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/list.go b/internal/app/list.go new file mode 100644 index 0000000..3b386d0 --- /dev/null +++ b/internal/app/list.go @@ -0,0 +1,86 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// listPages lists pages for a specific platform from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listPages(cli *cmd.CLI) int { + c := cache.New() + p := a.resolvePlatform(cli.Platform) + pages, err := c.ListFor(p) + if err != nil { + logger.Error("failed to list pages: %v", err) + return 1 + } + + for _, page := range pages { + if _, err := fmt.Fprintln(a.Stdout, page); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listAllPages lists all cached pages across all platforms. +// Returns 0 on success, 1 on error. +func (a *App) listAllPages() int { + c := cache.New() + pages, err := c.ListAll() + if err != nil { + logger.Error("failed to list pages: %v", err) + return 1 + } + + for _, page := range pages { + if _, err := fmt.Fprintln(a.Stdout, page); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listPlatforms lists all available platforms from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listPlatforms() int { + c := cache.New() + platforms, err := c.ListPlatforms() + if err != nil { + logger.Error("failed to list platforms: %v", err) + return 1 + } + + for _, p := range platforms { + if _, err := fmt.Fprintln(a.Stdout, p); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} + +// listLanguages lists all available languages from the cache. +// Returns 0 on success, 1 on error. +func (a *App) listLanguages() int { + c := cache.New() + languages, err := c.ListLanguages() + if err != nil { + logger.Error("failed to list languages: %v", err) + return 1 + } + + for _, l := range languages { + if _, err := fmt.Fprintln(a.Stdout, l); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/page.go b/internal/app/page.go new file mode 100644 index 0000000..73c262b --- /dev/null +++ b/internal/app/page.go @@ -0,0 +1,186 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/internal/render" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/pathutil" +) + +// lookupAndRenderPage finds a page by name and renders it to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { + p := a.resolvePlatform(cli.Platform) + langs := a.resolveLanguages(cli.Languages) + c := cache.New() + + query := strings.Join(cli.Page, "-") + results, err := c.Find(query, p, langs) + if err != nil { + logger.Error("failed to find page: %v", err) + return 1 + } + + pagePath, renderPlatform, err := a.selectPage( + results, + query, + p, + ) + if err != nil { + logger.Error("%v", err) + return 1 + } + + root, err := os.OpenRoot( + filepath.Dir(pagePath), + ) + if err != nil { + logger.Error("%v", err) + return 1 + } + + data, err := root.ReadFile( + filepath.Base(pagePath), + ) + if err != nil { + logger.Error("failed to read page: %v", err) + return 1 + } + + if err := render.Validate(string(data)); err != nil { + logger.Error("not a valid tldr page: %s\n\n%v", pagePath, err) + return 1 + } + + page := render.Parse(string(data)) + page.Path = pagePath + page.RawContent = string(data) + + renderer := render.New(a.Stdout, a.renderOptions(cli)...) + if err := renderer.Render(renderPlatform, page); err != nil { + logger.Error("failed to render page: %v", err) + return 1 + } + return 0 +} + +// renderLocalFile reads a local tldr markdown file, validates it, +// and renders it to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) renderLocalFile(cli *cmd.CLI) int { + root, err := os.OpenRoot( + filepath.Dir(cli.Render), + ) + if err != nil { + logger.Error("%v", err) + return 1 + } + + data, err := root.ReadFile( + filepath.Base(cli.Render), + ) + if err != nil { + logger.Error("failed to read file: %v", err) + return 1 + } + + if err := render.Validate(string(data)); err != nil { + logger.Error("not a valid tldr page: %s\n\n%v", cli.Render, err) + return 1 + } + + page := render.Parse(string(data)) + if page.Title == "" { + logger.Error("not a valid tldr page: %s", cli.Render) + return 1 + } + + page.Path = cli.Render + page.RawContent = string(data) + + renderer := render.New(a.Stdout, a.renderOptions(cli)...) + if err := renderer.Render("", page); err != nil { + logger.Error("failed to render: %v", err) + return 1 + } + return 0 +} + +// selectPage chooses the best matching page +// and falls back to pages from other platforms +// when no exact match exists. +func (a *App) selectPage( + results *cache.FindResult, + query, + requestedPlatform string, +) (string, string, error) { + if len(results.Fallbacks) > 0 { + logger.Warn( + "%d page(s) found for other platforms:", + len(results.Fallbacks), + ) + + for i, f := range results.Fallbacks { + platform := pathutil.PagePlatform(f) + _, err := fmt.Fprintf( + a.Stderr, + "%d. %s (tldr --platform %s %s)\n", + i+1, + platform, + platform, + query, + ) + if err != nil { + return "", "", err + } + } + } + + switch { + case len(results.Matches) > 0: + return results.Matches[0], requestedPlatform, nil + case len(results.Fallbacks) > 0: + page := results.Fallbacks[0] + return page, pathutil.PagePlatform(page), nil + + default: + return "", "", fmt.Errorf("page not found, try running tldr --update") + } +} + +// renderOptions builds the render options from the CLI flags and config. +func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { + opts := []render.RenderOption{ + render.WithWriter(a.Stdout), + } + switch cli.Color { + case "always": + opts = append(opts, render.WithColor(true)) + case "never": + opts = append(opts, render.WithColor(false)) + } + + output := config.Output() + switch { + case cli.Compact: + output.Compact = true + case cli.Raw: + output.RawMarkdown = true + case cli.Edit: + output.EditLink = true + case cli.ShortOptions: + output.OptionStyle = config.OptionStyleShort + case cli.LongOptions: + output.OptionStyle = config.OptionStyleLong + } + + opts = append(opts, render.WithOutput(output)) + return opts +} diff --git a/internal/app/search.go b/internal/app/search.go new file mode 100644 index 0000000..62d0fe4 --- /dev/null +++ b/internal/app/search.go @@ -0,0 +1,36 @@ +package app + +import ( + "fmt" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/logger" +) + +// searchPages searches cached pages for the given query. +// Returns 0 on success, 1 on error. +func (a *App) searchPages(cli *cmd.CLI) int { + c := cache.New() + p := a.resolvePlatform(cli.Platform) + languages := a.resolveLanguages(cli.Languages) + + results, err := c.Search(cli.Search, p, languages) + if err != nil { + logger.Error("search failed: %v", err) + return 1 + } + + for _, r := range results { + if _, err := fmt.Fprintf( + a.Stdout, + "%s/%s\n", + r.Platform, + r.Page, + ); err != nil { + logger.Error("%w", err) + return 1 + } + } + return 0 +} diff --git a/internal/app/update.go b/internal/app/update.go new file mode 100644 index 0000000..56dffc9 --- /dev/null +++ b/internal/app/update.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" +) + +// updateCache downloads the latest tldr-pages +// for the configured languages. +// Returns 0 on success, 1 on error. +func (a *App) updateCache(cli *cmd.CLI) int { + c := cache.New() + languages := a.resolveLanguages(cli.Languages) + client := upstream.New() + + if err := c.Update(context.Background(), languages, client); err != nil { + logger.Error("failed to update cache: %v", err) + return 1 + } + return 0 +} diff --git a/internal/cache/archive.go b/internal/cache/archive.go index c28c345..a2ae9d7 100644 --- a/internal/cache/archive.go +++ b/internal/cache/archive.go @@ -62,6 +62,10 @@ func (c *Cache) extractArchive( var extracted int for _, f := range zipReader.File { if strings.Contains(f.Name, "..") { + logger.Warn( + "skipping zip entry with '..': %s", + f.Name, + ) continue } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8d7d796..5bd07d7 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/slice" ) @@ -20,8 +21,10 @@ type Cache struct { // New creates a Cache using the cache directory from the config singleton. func New() *Cache { + dir := config.Cache().Dir + logger.Debug("cache dir: %s", dir) return &Cache{ - dir: config.Cache().Dir, + dir: dir, } } @@ -62,6 +65,11 @@ func (c *Cache) getPlatforms() ([]string, error) { sort.Strings(platforms) c.platforms.Store(platforms) + logger.Debug( + "discovered %d platforms from %s", + len(platforms), + englishDirectory, + ) return platforms, nil } diff --git a/internal/cache/checksums.go b/internal/cache/checksums.go index f22da23..7e4502b 100644 --- a/internal/cache/checksums.go +++ b/internal/cache/checksums.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/upstream" + "github.com/TheRootDaemon/tlgc/logger" ) // loadChecksums reads the cached checksum file from disk @@ -14,6 +15,7 @@ import ( func (c *Cache) loadChecksums() map[string]string { root, err := os.OpenRoot(c.dir) if err != nil { + logger.Trace("no existing checksums file") return nil } defer func() { @@ -22,10 +24,13 @@ func (c *Cache) loadChecksums() map[string]string { checksumBytes, err := root.ReadFile(checksumFile) if err != nil { + logger.Trace("no existing checksums file") return nil } - return parseChecksum(checksumBytes) + checksums := parseChecksum(checksumBytes) + logger.Debug("loaded %d checksums", len(checksums)) + return checksums } // saveChecksums writes the checksum map to disk in sha256sum format. @@ -67,6 +72,7 @@ func downloadChecksum( mirror string, ) ([]byte, error) { checksumURL := mirror + "/" + checksumFile + logger.Debug("fetching checksums from %s", checksumURL) return client.DownloadBytes(ctx, checksumURL, "") } diff --git a/internal/cache/find.go b/internal/cache/find.go index 69c02eb..26f3dde 100644 --- a/internal/cache/find.go +++ b/internal/cache/find.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/TheRootDaemon/tlgc/logger" ) // FindResult contains the pages found for a command lookup. @@ -30,6 +32,13 @@ type FindResult struct { // // Language directories are searched in the order provided by languages. func (c *Cache) Find(query, platform string, languages []string) (*FindResult, error) { + logger.Debug( + "find: query=%q, platform=%q, languages=%v", + query, + platform, + languages, + ) + languageDirectories := c.languagesToDirectories(languages, false) if len(languageDirectories) == 0 { return nil, fmt.Errorf("no matching language directories found in cache") @@ -60,6 +69,12 @@ func (c *Cache) Find(query, platform string, languages []string) (*FindResult, e languageDirectories, ) + logger.Debug( + "primary matches: %d, fallbacks: %d", + len(matches), + len(fallbacks), + ) + return &FindResult{ Matches: matches, Fallbacks: fallbacks, diff --git a/internal/cache/info.go b/internal/cache/info.go index 638f0e0..a1af424 100644 --- a/internal/cache/info.go +++ b/internal/cache/info.go @@ -9,6 +9,7 @@ import ( "github.com/TheRootDaemon/tlgc/format" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" ) // LanguageInfo contains cache statistics for a single language. @@ -48,6 +49,11 @@ func (c *Cache) Age() (time.Duration, error) { sumfile := filepath.Join(c.dir, checksumFile) fi, err := os.Stat(sumfile) if err != nil { + logger.Debug( + "cache age: stat failed for %s, falling back to %s", + sumfile, + c.dir, + ) fi, err = os.Stat(c.dir) if err != nil { return 0, err @@ -58,6 +64,7 @@ func (c *Cache) Age() (time.Duration, error) { age := time.Since(mod) if age < 0 { + logger.Warn("cache mtime is in the future: clock may be wrong") return 0, fmt.Errorf("cache mtime is in the future: clock issue") } @@ -68,6 +75,8 @@ func (c *Cache) Age() (time.Duration, error) { // including its location, age, configuration, // per-language page counts, and total page count. func (c *Cache) Info() (*InfoResult, error) { + logger.Debug("cache dir=%q", c.dir) + fi, err := os.Stat(c.dir) if err != nil { return nil, fmt.Errorf("cache directory %q: %s", c.dir, err) diff --git a/internal/cache/list.go b/internal/cache/list.go index ea4cb5d..11b9652 100644 --- a/internal/cache/list.go +++ b/internal/cache/list.go @@ -6,11 +6,13 @@ import ( "sort" "strings" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/slice" ) // ListFor returns all page names in the give platform (plus common). func (c *Cache) ListFor(platform string) ([]string, error) { + logger.Debug("platform=%q", platform) if _, err := c.getPlatforms(); err != nil { return nil, err } @@ -36,6 +38,7 @@ func (c *Cache) ListFor(platform string) ([]string, error) { // ListAll returns all page names across all platforms in English. func (c *Cache) ListAll() ([]string, error) { platforms, err := c.getPlatforms() + logger.Debug("%d platforms", len(platforms)) if err != nil { return nil, err } @@ -62,6 +65,7 @@ func (c *Cache) ListPlatforms() ([]string, error) { // ListLanguages returns the installed language codes (without the "pages." prefix). func (c *Cache) ListLanguages() ([]string, error) { directories, err := c.getLanguageDirectories() + logger.Debug("found %d languages", len(directories)) if err != nil { return nil, err } diff --git a/internal/cache/search.go b/internal/cache/search.go index d8002b2..e9c84b5 100644 --- a/internal/cache/search.go +++ b/internal/cache/search.go @@ -29,6 +29,8 @@ type SearchResult struct { // that platform and common are searched. // Results are returned sorted by page name. func (c *Cache) Search(query, platform string, languages []string) ([]SearchResult, error) { + logger.Debug("query=%q, platform=%q, languages=%v", query, platform, languages) + platforms, err := c.resolvePlatforms(platform) if err != nil { return nil, err @@ -84,10 +86,13 @@ func (c *Cache) resolvePlatforms(platform string) ([]string, error) { switch { case platform == "common": + logger.Debug("resolved platforms: [common]") return []string{"common"}, nil case platform != "": + logger.Debug("resolved platforms: [%s, common]", platform) return []string{platform, "common"}, nil default: + logger.Debug("resolved platforms: %v", platforms) return platforms, nil } } diff --git a/internal/cache/update.go b/internal/cache/update.go index 5ebceb1..260fe5b 100644 --- a/internal/cache/update.go +++ b/internal/cache/update.go @@ -17,6 +17,8 @@ func (c *Cache) Update( languages []string, client *upstream.Client, ) error { + logger.Info("updating cache...") + checksums, err := downloadChecksum(ctx, client, config.Cache().Mirror) if err != nil { return fmt.Errorf("downloading checksum: %s", err) @@ -25,6 +27,8 @@ func (c *Cache) Update( oldChecksums := c.loadChecksums() newChecksums := parseChecksum(checksums) + logger.Debug("checking %d languages for updates", len(languages)) + var downloaded int for _, language := range languages { updated, err := c.updateLanguage( @@ -53,6 +57,7 @@ func (c *Cache) Update( } c.platforms.Store([]string(nil)) + logger.Info("cache updated successfully") return nil } @@ -74,9 +79,11 @@ func (c *Cache) updateLanguage( oldChecksums, newChecksums, ) { + logger.Debug("language %q: up to date, skipped", language) return false, nil } + logger.Debug("language %q: downloading", language) hash := newChecksums[archiveName] data, err := downloadArchive( ctx, diff --git a/internal/config/config.go b/internal/config/config.go index a39e50a..2c2a455 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/BurntSushi/toml" + "github.com/TheRootDaemon/tlgc/logger" ) // Config is the top-level configuration structure. @@ -57,6 +58,7 @@ func DefaultConfig() (string, error) { // - Windows: %AppData%/tlgc/config.toml func ConfigPath() string { if p := os.Getenv("TLGC_CONFIG"); p != "" { + logger.Trace("config path from TLGC_CONFIG=%s", p) return p } @@ -74,6 +76,7 @@ func ConfigPath() string { // Fields not present in the file // retain their default values. func LoadConfig(path string) (*Config, error) { + logger.Trace("loading config from %s", path) cfg := Default() _, err := toml.DecodeFile(path, &cfg) if err != nil { diff --git a/internal/config/singleton.go b/internal/config/singleton.go index 9ae627e..4ee82ff 100644 --- a/internal/config/singleton.go +++ b/internal/config/singleton.go @@ -1,23 +1,41 @@ package config import ( + "os" "sync/atomic" + + "github.com/TheRootDaemon/tlgc/logger" ) // currentConfig represents the singleton // of the current client configuration. var currentConfig atomic.Pointer[Config] -// Initialize loads a TOML config file and sets it as the global singleton. -// It is safe to call from multiple goroutines. -// Subsequent calls replace the current singleton. +// Initialize loads the TOML config file and sets the global singleton. +// If the config file does not exist, the singleton is set to defaults +// and no error is returned. It is safe to call from multiple goroutines. func Initialize() error { - cfg, err := LoadConfig(ConfigPath()) + path := ConfigPath() + logger.Debug("config path: %s", path) + + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + logger.Debug("config file not found, using defaults") + d := Default() + currentConfig.Store(&d) + return nil + } + logger.Warn("config stat failed: %v", err) + return err + } + + cfg, err := LoadConfig(path) if err != nil { return err } currentConfig.Store(cfg) + logger.Debug("config loaded from %s", path) return nil } diff --git a/internal/config/singleton_test.go b/internal/config/singleton_test.go index da929f6..10f1c50 100644 --- a/internal/config/singleton_test.go +++ b/internal/config/singleton_test.go @@ -41,12 +41,30 @@ func TestInitialize_and_C(t *testing.T) { assert.Equal(t, DefaultCacheConfig(), cfg.Cache) } -func TestInitialize_Error(t *testing.T) { +func TestInitialize_MissingFileDefaults(t *testing.T) { resetCurrentConfig() defer resetCurrentConfig() t.Setenv("TLGC_CONFIG", "/nonexistent/path/config.toml") err := Initialize() + require.NoError(t, err) + + cfg := C() + require.NotNil(t, cfg) + assert.Equal(t, Default(), *cfg) +} + +func TestInitialize_MalformedFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + err := os.WriteFile(path, []byte("invalid toml content {{{"), 0o644) + require.NoError(t, err) + + resetCurrentConfig() + defer resetCurrentConfig() + + t.Setenv("TLGC_CONFIG", path) + err = Initialize() require.Error(t, err) } diff --git a/internal/render/command.go b/internal/render/command.go index 1c1c223..d4c7546 100644 --- a/internal/render/command.go +++ b/internal/render/command.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/text" ) @@ -26,6 +27,7 @@ type mappedWord struct { func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { mappedWords := mapWords(segments, r.output.OptionStyle) if len(mappedWords) == 0 { + logger.Trace("no mapped words, skipped") return nil } @@ -37,6 +39,11 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { displayText, ) + logger.Trace( + "segments=%d mappedWords=%d lines=%d", + len(segments), len(mappedWords), len(lines), + ) + wordOffset := 0 for _, line := range lines { @@ -72,6 +79,7 @@ func (r *Renderer) renderCommandLine( indent string, wordOffset *int, ) error { + logger.Trace("words=%d offset=%d", len(words), *wordOffset) _, err := io.WriteString(w, indent) if err != nil { return err @@ -153,9 +161,17 @@ func wrapLines( ) []string { var wrapped string if width <= 0 { + logger.Trace("no wrap (width=%d)", width) return []string{displayText} } wrapped = text.Wrap(displayText, width, indent) - return strings.Split(wrapped, "\n") + lines := strings.Split(wrapped, "\n") + logger.Trace( + "width=%d input=%d output=%d lines", + width, + len(displayText), + len(lines), + ) + return lines } diff --git a/internal/render/command_test.go b/internal/render/command_test.go index e1e6e99..1f65780 100644 --- a/internal/render/command_test.go +++ b/internal/render/command_test.go @@ -149,6 +149,111 @@ func TestRenderCommand(t *testing.T) { }) } +func TestRenderCommandLine(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + } + + tests := []struct { + name string + words []string + mappedWords []mappedWord + segments []Segment + indent string + want string + wantOffset int + writer io.Writer + wantErr string + }{ + { + name: "single word", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + want: " hello\n", + wantOffset: 1, + }, + { + name: "multiple words", + words: []string{"tar", "cf", "archive.tar"}, + mappedWords: []mappedWord{ + {text: "tar", segmentIndex: 0}, + {text: "cf", segmentIndex: 0}, + {text: "archive.tar", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "tar cf archive.tar"}, + }, + indent: " ", + want: " tar cf archive.tar\n", + wantOffset: 3, + }, + { + name: "break when mapped words exhausted", + words: []string{"a", "b", "c"}, + mappedWords: []mappedWord{ + {text: "a", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "a"}, + }, + indent: " ", + want: " a \n", + wantOffset: 1, + }, + { + name: "write error", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset := 0 + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderCommandLine( + w, + tt.words, + tt.mappedWords, + tt.segments, + tt.indent, + &offset, + ) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantOffset, offset) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + func TestMapWords(t *testing.T) { t.Parallel() @@ -363,108 +468,3 @@ func TestWrapLines(t *testing.T) { }) } } - -func TestRenderCommandLine(t *testing.T) { - t.Parallel() - - r := &Renderer{ - useColor: false, - } - - tests := []struct { - name string - words []string - mappedWords []mappedWord - segments []Segment - indent string - want string - wantOffset int - writer io.Writer - wantErr string - }{ - { - name: "single word", - words: []string{"hello"}, - mappedWords: []mappedWord{ - {text: "hello", segmentIndex: 0}, - }, - segments: []Segment{ - {Kind: Text, Text: "hello"}, - }, - indent: " ", - want: " hello\n", - wantOffset: 1, - }, - { - name: "multiple words", - words: []string{"tar", "cf", "archive.tar"}, - mappedWords: []mappedWord{ - {text: "tar", segmentIndex: 0}, - {text: "cf", segmentIndex: 0}, - {text: "archive.tar", segmentIndex: 0}, - }, - segments: []Segment{ - {Kind: Text, Text: "tar cf archive.tar"}, - }, - indent: " ", - want: " tar cf archive.tar\n", - wantOffset: 3, - }, - { - name: "break when mapped words exhausted", - words: []string{"a", "b", "c"}, - mappedWords: []mappedWord{ - {text: "a", segmentIndex: 0}, - }, - segments: []Segment{ - {Kind: Text, Text: "a"}, - }, - indent: " ", - want: " a \n", - wantOffset: 1, - }, - { - name: "write error", - words: []string{"hello"}, - mappedWords: []mappedWord{ - {text: "hello", segmentIndex: 0}, - }, - segments: []Segment{ - {Kind: Text, Text: "hello"}, - }, - indent: " ", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - offset := 0 - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderCommandLine( - w, - tt.words, - tt.mappedWords, - tt.segments, - tt.indent, - &offset, - ) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.wantOffset, offset) - assert.Equal(t, tt.want, buf.String()) - }) - } -} diff --git a/internal/render/description.go b/internal/render/description.go new file mode 100644 index 0000000..f84e9f3 --- /dev/null +++ b/internal/render/description.go @@ -0,0 +1,109 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/logger" +) + +// renderDescriptionLine writes one description line, +// styled with r.style.Description, indented, +// and followed by a newline. +func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error { + logger.Trace("text=%q", text) + if err := r.renderStyledInline( + w, + text, + indent, + r.style.Description, + r.style.InlineCode, + ); err != nil { + return err + } + + _, err := io.WriteString(w, "\n") + return err +} + +// renderDescriptions writes all description lines +// followed by the "More information" URL (if set), +// each indented by r.indent.Description. +// Writes a trailing blank line after descriptions. +func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) error { + if len(descs) == 0 && url == "" { + return nil + } + + logger.Trace("count=%d hasURL=%t", len(descs), url != "") + + indent := strings.Repeat(" ", r.indent.Description) + + for _, d := range descs { + if err := r.renderDescriptionLine(w, d, indent); err != nil { + return err + } + } + + if url != "" { + if err := r.renderDescriptionURL(w, url, indent); err != nil { + return err + } + } + + if !r.output.Compact { + _, err := io.WriteString(w, "\n") + return err + } + + return nil +} + +// renderDescriptionURL writes the "More information: ." line, +// followed by a newline. +// The "More information: " prefix and trailing "." use r.style.Description; +// the URL itself uses r.style.URL. +// The line is indented and wrapped via wrapText. +func (r *Renderer) renderDescriptionURL(w io.Writer, url, indent string) error { + var text strings.Builder + text.WriteString( + r.applyStyle( + r.style.Description, + "More information: ", + ), + ) + text.WriteString( + r.applyStyle( + r.style.URL, + url, + ), + ) + text.WriteString( + r.applyStyle( + r.style.Description, + ".", + ), + ) + + wrappedText := r.wrapText(text.String(), indent) + + _, err := io.WriteString( + w, + wrappedText+"\n", + ) + return err +} + +// renderBulletLine writes one bullet item line, +// styled with r.style.Bullet and indented. +// No trailing newline is added. +func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error { + logger.Trace("text=%q", text) + return r.renderStyledInline( + w, + text, + indent, + r.style.Bullet, + r.style.InlineCode, + ) +} diff --git a/internal/render/description_test.go b/internal/render/description_test.go new file mode 100644 index 0000000..278fe75 --- /dev/null +++ b/internal/render/description_test.go @@ -0,0 +1,273 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderDescriptionLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text with trailing newline", + text: "hello", + indent: " ", + want: " hello\n", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptionLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderDescriptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + descs []string + url string + compact bool + writer io.Writer + want string + wantErr string + }{ + { + name: "no descriptions and no url returns nil", + descs: nil, + url: "", + want: "", + }, + { + name: "single description", + descs: []string{"hello"}, + want: " hello\n\n", + }, + { + name: "single description compact omits trailing newline", + descs: []string{"hello"}, + compact: true, + want: " hello\n", + }, + { + name: "multiple descriptions", + descs: []string{"first", "second"}, + want: " first\n second\n\n", + }, + { + name: "description with URL", + descs: []string{"hello"}, + url: "https://example.org", + want: " hello\n More information: https://example.org.\n\n", + }, + { + name: "URL only no descriptions", + url: "https://example.org", + want: " More information: https://example.org.\n\n", + }, + { + name: "URL only compact", + url: "https://example.org", + compact: true, + want: " More information: https://example.org.\n", + }, + { + name: "write error", + descs: []string{"hello"}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + indent: config.IndentConfig{Description: 2}, + output: config.OutputConfig{Compact: tt.compact}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptions(w, tt.descs, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderDescriptionURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + indent string + lineLength int + useColor bool + descStyle config.OutputStyle + urlStyle config.OutputStyle + writer io.Writer + want string + wantANSI bool + wantErr string + }{ + { + name: "writes more information line", + url: "https://example.org", + indent: " ", + want: " More information: https://example.org.\n", + }, + { + name: "url includes ANSI styles when color enabled", + url: "https://example.org", + indent: " ", + useColor: true, + descStyle: config.OutputStyle{Bold: true}, + urlStyle: config.OutputStyle{Italic: true}, + wantANSI: true, + }, + { + name: "write error", + url: "https://example.org", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := &Renderer{ + useColor: tt.useColor, + style: config.StyleConfig{ + Description: tt.descStyle, + URL: tt.urlStyle, + }, + output: config.OutputConfig{LineLength: tt.lineLength}, + } + + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptionURL(w, tt.url, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + got := buf.String() + + if tt.wantANSI { + assert.Contains(t, got, "\x1b[") + assert.Contains(t, got, "More information:") + assert.Contains(t, got, "https://example.org") + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestRenderBulletLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text without trailing newline", + text: "hello", + indent: " ", + want: " hello", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderBulletLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/internal/render/edit.go b/internal/render/edit.go new file mode 100644 index 0000000..8fb4a67 --- /dev/null +++ b/internal/render/edit.go @@ -0,0 +1,67 @@ +package render + +import ( + "fmt" + "io" + + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/pathutil" +) + +// renderEditLink writes the edit URL to w, with a trailing newline +// unless output is in compact mode. +func (r *Renderer) renderEditLink(w io.Writer, url string) error { + logger.Info("edit this page on GitHub") + logger.Trace("url=%q compact=%t", url, r.output.Compact) + _, err := io.WriteString(w, url) + if err != nil { + return err + } + + if !r.output.Compact { + _, err := io.WriteString(w, "\n") + return err + } + + return nil +} + +// renderPageEditLink renders the edit link +// for p when edit links are enabled. +func (r *Renderer) renderPageEditLink(p *Page) error { + if !r.output.EditLink { + return nil + } + + url := buildEditURL(p.Path, p.URL) + if url == "" { + return nil + } + + return r.renderEditLink(r.w, url) +} + +// buildEditURL returns the GitHub edit URL for a tldr page. +// If url is non-empty it is returned as-is (the page has a custom source). +// Otherwise the URL is constructed from the page's file path. +func buildEditURL(path, url string) string { + if url != "" { + logger.Trace("using custom url=%s", url) + return url + } + + if path != "" { + page := pathutil.PageName(path) + platform := pathutil.PagePlatform(path) + result := fmt.Sprintf( + "https://github.com/tldr-pages/tldr/edit/main/pages/%s/%s.md", + platform, + page, + ) + logger.Trace("path=%s -> %s", path, result) + return result + } + + logger.Trace("empty path and url") + return "" +} diff --git a/internal/render/edit_test.go b/internal/render/edit_test.go new file mode 100644 index 0000000..b7f71b4 --- /dev/null +++ b/internal/render/edit_test.go @@ -0,0 +1,203 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderEditLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + compact bool + url string + writer io.Writer + want string + wantErr string + }{ + { + name: "non-compact adds newline", + compact: false, + url: "https://example.com", + want: "https://example.com\n", + }, + { + name: "compact omits newline", + compact: true, + url: "https://example.com", + want: "https://example.com", + }, + { + name: "write error", + compact: false, + url: "url", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{Compact: tt.compact}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderEditLink(w, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderPageEditLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + page *Page + editLink bool + compact bool + writer io.Writer + want string + wantErr string + }{ + { + name: "edit link disabled", + editLink: false, + page: &Page{}, + want: "", + }, + { + name: "empty path and url", + editLink: true, + page: &Page{}, + want: "", + }, + { + name: "renders edit link from path", + editLink: true, + page: &Page{Path: "/pages/common/tar.md"}, + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md\n", + }, + { + name: "custom url returned as-is", + editLink: true, + page: &Page{URL: "https://custom.com/edit"}, + want: "https://custom.com/edit\n", + }, + { + name: "compact mode omits newline", + editLink: true, + compact: true, + page: &Page{Path: "/pages/common/tar.md"}, + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md", + }, + { + name: "write error", + editLink: true, + page: &Page{URL: "https://example.com"}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := &Renderer{ + w: &buf, + output: config.OutputConfig{EditLink: tt.editLink, Compact: tt.compact}, + } + + if tt.writer != nil { + r.w = tt.writer + } + + err := r.renderPageEditLink(tt.page) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestBuildEditURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + url string + want string + }{ + { + name: "non-empty url returned as-is", + path: "/pages/common/tar.md", + url: "https://example.com", + want: "https://example.com", + }, + { + name: "empty url constructs from path", + path: "/pages/common/tar.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md", + }, + { + name: "linux platform extracted correctly", + path: "/pages/linux/apt.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/linux/apt.md", + }, + { + name: "windows platform extracted correctly", + path: "/pages/windows/dir.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/windows/dir.md", + }, + { + name: "empty path and empty url returns empty", + path: "", + url: "", + want: "", + }, + { + name: "path without .md extension adds .md", + path: "/pages/common/some-page", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/some-page.md", + }, + { + name: "url takes precedence over path", + path: "/pages/common/tar.md", + url: "https://custom.com/edit", + want: "https://custom.com/edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildEditURL(tt.path, tt.url) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/example.go b/internal/render/example.go new file mode 100644 index 0000000..4375c27 --- /dev/null +++ b/internal/render/example.go @@ -0,0 +1,74 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/logger" +) + +// renderExample writes one example, +// a bullet line for the description (prefixed with ExamplePrefix when ShowHyphens is set) +// followed by the styled command text on the next line. +// In compact mode the blank line between bullet and command is omitted. +func (r *Renderer) renderExample(w io.Writer, ex Example) error { + logger.Trace( + "desc=%q hasCmd=%t compact=%t", + ex.Description, + ex.Command != "", + r.output.Compact, + ) + indent := strings.Repeat(" ", r.indent.Bullet) + desc := ex.Description + + if r.output.ShowHyphens { + desc = r.output.ExamplePrefix + desc + } + + if err := r.renderBulletLine(w, desc, indent); err != nil { + return err + } + + if !r.output.Compact { + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + } + + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + + if ex.Command != "" { + segments := ParseCommand(ex.Command) + if err := r.renderCommand(w, segments); err != nil { + return err + } + } + + return nil +} + +// renderExamples renders a sequence of examples to w. +// +// Each example is rendered using renderExample. +// In non-compact mode, a blank line is inserted +// between consecutive examples +func (r *Renderer) renderExamples(w io.Writer, examples []Example) error { + for i, ex := range examples { + if i > 0 && !r.output.Compact { + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + } + + if err := r.renderExample(r.w, ex); err != nil { + return err + } + } + + return nil +} diff --git a/internal/render/example_test.go b/internal/render/example_test.go new file mode 100644 index 0000000..6993775 --- /dev/null +++ b/internal/render/example_test.go @@ -0,0 +1,165 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderExample(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ex Example + indent config.IndentConfig + output config.OutputConfig + writer io.Writer + want string + wantErr string + }{ + { + name: "description and command non-compact", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create archive\n\n tar cf archive.tar\n", + }, + { + name: "description only no command", + ex: Example{Description: "just a description"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " just a description\n\n", + }, + { + name: "hyphens enabled", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{ShowHyphens: true, ExamplePrefix: "- "}, + want: " - create archive\n\n tar cf archive.tar\n", + }, + { + name: "compact mode no blank line", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{Compact: true}, + want: " create archive\n tar cf archive.tar\n", + }, + { + name: "write error", + ex: Example{Description: "error"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: tt.output, + indent: tt.indent, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderExample(w, tt.ex) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderExamples(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + examples []Example + compact bool + indent config.IndentConfig + writer io.Writer + want string + wantErr string + }{ + { + name: "empty examples", + examples: nil, + want: "", + }, + { + name: "single example", + examples: []Example{{Description: "create archive", Command: "tar cf archive.tar"}}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create archive\n\n tar cf archive.tar\n", + }, + { + name: "multiple examples non-compact", + examples: []Example{ + {Description: "create", Command: "tar cf"}, + {Description: "extract", Command: "tar xf"}, + }, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create\n\n tar cf\n\n extract\n\n tar xf\n", + }, + { + name: "multiple examples compact", + examples: []Example{ + {Description: "create", Command: "tar cf"}, + {Description: "extract", Command: "tar xf"}, + }, + compact: true, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create\n tar cf\n extract\n tar xf\n", + }, + { + name: "write error", + examples: []Example{{Description: "error"}}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := &Renderer{ + w: &buf, + output: config.OutputConfig{Compact: tt.compact}, + indent: tt.indent, + } + + w := io.Writer(&buf) + if tt.writer != nil { + r.w = tt.writer + w = tt.writer + } + + err := r.renderExamples(w, tt.examples) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/internal/render/inline.go b/internal/render/inline.go new file mode 100644 index 0000000..009044a --- /dev/null +++ b/internal/render/inline.go @@ -0,0 +1,198 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" +) + +// inlineSegment is one piece of inline content +// within a description or bullet line. +// code is true when the text was wrapped in backticks +// and should be styled with the InlineCode style. +type inlineSegment struct { + code bool + text string +} + +// renderStyledInline writes text, +// splitting backtick-delimited code spans +// so plain segments use baseStyle and +// code segments use codeStyle. +// When no backticks are present, +// it falls through to the single-segment fast path (applyStyle + wrapText). +func (r *Renderer) renderStyledInline( + w io.Writer, + text, + indent string, + baseStyle, + codeStyle config.OutputStyle, +) error { + logger.Trace("text=%q len=%d", text, len(text)) + + segments := parseInline(text) + if len(segments) == 1 { + _, err := io.WriteString( + w, + r.applyStyle( + baseStyle, + r.wrapText(text, indent), + ), + ) + return err + } + + wrappedLines := r.wrapInlineSegments(segments, indent) + + for i, line := range wrappedLines { + if i > 0 { + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + } + + if err := r.renderStyledLine( + w, + line, + segments, + baseStyle, + codeStyle, + ); err != nil { + return err + } + } + + return nil +} + +// wrapInlineSegments joins all segment text into a single string, +// wraps it via wrapText, and returns the individual lines. +func (r *Renderer) wrapInlineSegments( + segments []inlineSegment, + indent string, +) []string { + var plainText strings.Builder + for _, segment := range segments { + plainText.WriteString(segment.text) + } + + wrapped := r.wrapText(plainText.String(), indent) + return strings.Split(wrapped, "\n") +} + +// renderStyledLine writes one wrapped line, +// applying baseStyle or codeStyle to each segment +// based on its code field. +// Segments are matched to the line text +// by position to avoid false matches from repeated words. +func (r *Renderer) renderStyledLine( + w io.Writer, + line string, + segments []inlineSegment, + baseStyle, + codeStyle config.OutputStyle, +) error { + if line == "" { + return nil + } + + remaining := line + for _, segment := range segments { + if segment.text == "" { + continue + } + + if remaining == "" { + break + } + + pos := strings.Index(remaining, segment.text) + if pos < 0 { + break + } + if pos > 0 { + before := remaining[:pos] + remaining = remaining[pos:] + _, err := io.WriteString( + w, + r.applyStyle(baseStyle, before), + ) + if err != nil { + return err + } + } + if len(segment.text) > len(remaining) { + break + } + piece := remaining[:len(segment.text)] + remaining = remaining[len(segment.text):] + if segment.code { + _, err := io.WriteString(w, r.applyStyle(codeStyle, piece)) + if err != nil { + return err + } + } else { + _, err := io.WriteString(w, r.applyStyle(baseStyle, piece)) + if err != nil { + return err + } + } + } + if remaining != "" { + _, err := io.WriteString(w, r.applyStyle(baseStyle, remaining)) + if err != nil { + return err + } + } + + return nil +} + +// parseInline splits s by backtick-delimited code spans. +// Even-index parts are plain text, odd-index parts are code spans. +// Returns at minimum one segment (the whole text as non-code). +func parseInline(s string) []inlineSegment { + if !strings.Contains(s, "`") { + return []inlineSegment{ + { + text: s, + }, + } + } + + parts := strings.Split(s, "`") + segments := make([]inlineSegment, 0, len(parts)) + + for i, p := range parts { + if p == "" && i%2 == 0 { + continue + } + segments = append( + segments, + inlineSegment{ + text: p, + code: i%2 == 1, + }, + ) + } + + // drop trailing empty text segments + for len(segments) > 0 && + !segments[len(segments)-1].code && + segments[len(segments)-1].text == "" { + segments = segments[:len(segments)-1] + } + + if len(segments) == 0 { + segments = []inlineSegment{ + { + text: s, + }, + } + } + + return segments +} diff --git a/internal/render/inline_test.go b/internal/render/inline_test.go new file mode 100644 index 0000000..e88b852 --- /dev/null +++ b/internal/render/inline_test.go @@ -0,0 +1,439 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +// failOnNewline is a writer that succeeds on all writes except standalone "\n". +// Used to test error propagation on the newline write between wrapped lines. +type failOnNewline struct { + buf strings.Builder +} + +// Write writes p to the underlying buffer +// unless p is exactly "\n", +// in which case it returns a sentinel write error. +// This allows tests to simulate a failure during newline writes. +func (w *failOnNewline) Write(p []byte) (int, error) { + if string(p) == "\n" { + return 0, errors.New("write error") + } + return w.buf.Write(p) +} + +func TestRenderStyledInline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + lineLength int + useColor bool + baseStyle config.OutputStyle + codeStyle config.OutputStyle + writer io.Writer + want string + wantErr string + wantANSI bool + }{ + { + name: "fast path single segment no backticks", + text: "hello world", + indent: " ", + want: " hello world", + }, + { + name: "multiple segments no wrap", + text: "hello `code` world", + indent: " ", + want: " hello code world", + }, + { + name: "wrapping across lines", + text: "some `code` here and there", + indent: " ", + lineLength: 15, + want: " some code here\n and there", + }, + { + name: "color output contains ANSI escapes", + text: "hello `code` world", + indent: " ", + useColor: true, + baseStyle: config.OutputStyle{Bold: true}, + codeStyle: config.OutputStyle{Italic: true}, + wantANSI: true, + }, + { + name: "no color output is plain text", + text: "hello `code` world", + indent: " ", + want: " hello code world", + }, + { + name: "write error on newline between lines", + text: "aaa bbbb `cccc` dddd eeee", + indent: " ", + lineLength: 15, + writer: &failOnNewline{}, + wantErr: "write error", + }, + { + name: "write error on first content write", + text: "hello world", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + output: config.OutputConfig{LineLength: tt.lineLength}, + style: config.StyleConfig{ + Description: tt.baseStyle, + InlineCode: tt.codeStyle, + }, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderStyledInline( + w, + tt.text, + tt.indent, + r.style.Description, + r.style.InlineCode, + ) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + got := buf.String() + + if tt.wantANSI { + assert.Contains(t, got, "\x1b[") + assert.Contains(t, got, "hello") + assert.Contains(t, got, "code") + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestWrapInlineSegments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segments []inlineSegment + indent string + lineLength int + want []string + }{ + { + name: "single segment one line", + segments: []inlineSegment{{text: "hello"}}, + indent: " ", + want: []string{" hello"}, + }, + { + name: "multiple segments no wrap", + segments: []inlineSegment{{text: "hello "}, {text: "code", code: true}}, + indent: " ", + want: []string{" hello code"}, + }, + { + name: "wrapping at boundary produces two lines", + segments: []inlineSegment{{text: "aaa "}, {text: "bbb", code: true}, {text: " ccc"}}, + indent: " ", + lineLength: 10, + want: []string{" aaa bbb", " ccc"}, + }, + { + name: "wrapping produces multiple lines", + segments: []inlineSegment{{text: "aaa bbb ccc ddd eee"}}, + indent: " ", + lineLength: 10, + want: []string{" aaa bbb", " ccc ddd", " eee"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{LineLength: tt.lineLength}, + } + got := r.wrapInlineSegments(tt.segments, tt.indent) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRenderStyledLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line string + segments []inlineSegment + writer io.Writer + want string + wantErr string + }{ + { + name: "single text segment", + line: "hello", + segments: []inlineSegment{{text: "hello"}}, + want: "hello", + }, + { + name: "single code segment", + line: "hello", + segments: []inlineSegment{{text: "hello", code: true}}, + want: "hello", + }, + { + name: "multiple segments mixed", + line: "hello world", + segments: []inlineSegment{ + {text: "hello "}, + {text: "world", code: true}, + }, + want: "hello world", + }, + { + name: "segment text not found writes remaining", + line: "hello", + segments: []inlineSegment{{text: "xyz"}}, + want: "hello", + }, + { + name: "empty line returns nil", + line: "", + segments: []inlineSegment{{text: "hello"}}, + want: "", + }, + { + name: "remaining text after segments consumed", + line: "hello world extra", + segments: []inlineSegment{ + {text: "hello "}, + {text: "world", code: true}, + }, + want: "hello world extra", + }, + { + name: "break writes remaining when segment not found mid-line", + line: "hello world", + segments: []inlineSegment{ + {text: "hello"}, + {text: "xyz"}, + }, + want: "hello world", + }, + { + name: "write error on before text", + line: " hello", + segments: []inlineSegment{{text: "hello"}}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + { + name: "write error on code segment", + line: "hello", + segments: []inlineSegment{{text: "hello", code: true}}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + { + name: "write error on remaining text", + line: "hello", + segments: []inlineSegment{{text: "xyz"}}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + { + name: "empty segment text skipped", + line: "hello", + segments: []inlineSegment{ + {text: ""}, + {text: "hello"}, + }, + want: "hello", + }, + { + name: "repeated segment text positional matching", + line: "a a", + segments: []inlineSegment{ + {text: "a"}, + {text: "a"}, + }, + want: "a a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderStyledLine( + w, + tt.line, + tt.segments, + config.OutputStyle{}, + config.OutputStyle{}, + ) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestParseInline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want []inlineSegment + }{ + { + name: "no backticks", + s: "hello world", + want: []inlineSegment{{text: "hello world"}}, + }, + { + name: "empty string", + s: "", + want: []inlineSegment{{text: ""}}, + }, + { + name: "single code span", + s: "`code`", + want: []inlineSegment{{text: "code", code: true}}, + }, + { + name: "plain text with code span", + s: "hello `code` world", + want: []inlineSegment{ + {text: "hello "}, + {text: "code", code: true}, + {text: " world"}, + }, + }, + { + name: "multiple code spans", + s: "`a` and `b`", + want: []inlineSegment{ + {text: "a", code: true}, + {text: " and "}, + {text: "b", code: true}, + }, + }, + { + name: "leading plain text before code", + s: "text `code`", + want: []inlineSegment{ + {text: "text "}, + {text: "code", code: true}, + }, + }, + { + name: "trailing plain text after code", + s: "`code` text", + want: []inlineSegment{ + {text: "code", code: true}, + {text: " text"}, + }, + }, + { + name: "adjacent code spans", + s: "`a``b`", + want: []inlineSegment{ + {text: "a", code: true}, + {text: "b", code: true}, + }, + }, + { + name: "only double backticks", + s: "``", + want: []inlineSegment{{text: "", code: true}}, + }, + { + name: "only triple backticks", + s: "```", + want: []inlineSegment{ + {text: "", code: true}, + {text: "", code: true}, + }, + }, + { + name: "three consecutive code spans", + s: "`x``y``z`", + want: []inlineSegment{ + {text: "x", code: true}, + {text: "y", code: true}, + {text: "z", code: true}, + }, + }, + { + name: "code plain code", + s: "`a` x `b`", + want: []inlineSegment{ + {text: "a", code: true}, + {text: " x "}, + {text: "b", code: true}, + }, + }, + { + name: "plain text then backtick", + s: "text`", + want: []inlineSegment{ + {text: "text"}, + {text: "", code: true}, + }, + }, + { + name: "opening backtick only", + s: "`hello", + want: []inlineSegment{{text: "hello", code: true}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInline(tt.s) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/page.go b/internal/render/page.go index f09e13f..04ffa23 100644 --- a/internal/render/page.go +++ b/internal/render/page.go @@ -1,10 +1,12 @@ package render import ( + "fmt" "regexp" "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" ) var ( @@ -59,10 +61,43 @@ type Page struct { Title string URL string Path string + RawContent string Description []string Examples []Example } +// Validate checks that every non-empty line in content +// starts with a valid tldr prefix (#, >, -, or `). +// Returns nil if the content is valid, +// or an error describing the first invalid line. +func Validate(content string) error { + lines := strings.SplitSeq(content, "\n") + lineNum := 0 + + for line := range lines { + lineNum++ + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if strings.HasPrefix(line, "# ") || + strings.HasPrefix(line, "> ") || + strings.HasPrefix(line, "- ") || + (strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`")) { + continue + } + + return fmt.Errorf( + "line %d: %q does not start with a valid prefix "+ + "(must begin with '# ', '> ', '- ', or '`')", + lineNum, trimmed, + ) + } + + return nil +} + // Parse parses a raw markdown tldr page string into a Page. // // It recognises the following markdown structure: @@ -117,6 +152,10 @@ func Parse(content string) *Page { } } + logger.Debug( + "title=%q descs=%d examples=%d url=%s", + p.Title, len(p.Description), len(p.Examples), p.URL, + ) return p } @@ -133,6 +172,7 @@ func ParseCommand(raw string) []Segment { matches := placeholderPattern.FindAllStringSubmatchIndex(raw, -1) if len(matches) == 0 { + logger.Trace("no placeholders, raw=%q", raw) return []Segment{ { Kind: Text, @@ -179,6 +219,7 @@ func ParseCommand(raw string) []Segment { ) } + logger.Trace("raw=%q -> %d segments", raw, len(segments)) return segments } @@ -242,6 +283,7 @@ func parseInnerPlaceholders(inner string) Segment { short, long = right, left } + logger.Trace("option short=%q long=%q", short, long) return Segment{ Kind: Option, Long: long, @@ -249,6 +291,7 @@ func parseInnerPlaceholders(inner string) Segment { } } + logger.Trace("placeholder text=%q", inner) return Segment{ Kind: Placeholder, Text: inner, diff --git a/internal/render/page_test.go b/internal/render/page_test.go index 7583674..a36db94 100644 --- a/internal/render/page_test.go +++ b/internal/render/page_test.go @@ -8,6 +8,77 @@ import ( "github.com/stretchr/testify/require" ) +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr string + }{ + { + name: "valid page", + content: "# tar\n\n> archive utility.\n\n- create:\n\n`tar cf archive.tar`\n", + }, + { + name: "title only", + content: "# tar\n", + }, + { + name: "description only with URL", + content: "# tar\n\n> archive utility.\n> More information: .\n", + }, + { + name: "command without description", + content: "# tar\n\n`tar --help`\n", + }, + { + name: "empty content", + content: "", + }, + { + name: "blank lines only", + content: "\n\n\n", + }, + { + name: "invalid line at start", + content: "some random text\n", + wantErr: `line 1: "some random text" does not start with a valid tldr prefix`, + }, + { + name: "invalid line after title", + content: "# tar\n\nThis is not valid.\n", + wantErr: `line 3: "This is not valid." does not start with a valid tldr prefix`, + }, + { + name: "multiple lines, first invalid", + content: "bad\n# tar\n", + wantErr: `line 1: "bad" does not start with a valid tldr prefix`, + }, + { + name: "hash without space", + content: "#wrong\n", + wantErr: `line 1: "#wrong" does not start with a valid tldr prefix`, + }, + { + name: "dash without space", + content: "-wrong\n", + wantErr: `line 1: "-wrong" does not start with a valid tldr prefix`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.content) + if tt.wantErr != "" { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestParse(t *testing.T) { t.Parallel() diff --git a/internal/render/raw.go b/internal/render/raw.go new file mode 100644 index 0000000..fe24721 --- /dev/null +++ b/internal/render/raw.go @@ -0,0 +1,28 @@ +package render + +import ( + "os" + + "github.com/TheRootDaemon/tlgc/logger" +) + +// renderRaw reads the raw markdown file at p.Path and +// writes it to the Renderer's writer. +func (r *Renderer) renderRaw(p *Page) error { + if p.RawContent != "" { + logger.Trace("using RawContent (%d bytes)", len(p.RawContent)) + _, err := r.w.Write( + []byte(p.RawContent), + ) + return err + } + + logger.Trace("reading from path=%s", p.Path) + data, err := os.ReadFile(p.Path) + if err != nil { + return err + } + + _, err = r.w.Write(data) + return err +} diff --git a/internal/render/raw_test.go b/internal/render/raw_test.go new file mode 100644 index 0000000..5e83b1e --- /dev/null +++ b/internal/render/raw_test.go @@ -0,0 +1,89 @@ +package render + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderRaw(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawContent string + path string + content string + writer io.Writer + want string + wantErr string + wantAnyErr bool + }{ + { + name: "uses RawContent when set", + rawContent: "# hello from content", + want: "# hello from content", + }, + { + name: "RawContent takes precedence over Path", + rawContent: "from content", + path: "/some/nonexistent/path.md", + want: "from content", + }, + { + name: "falls back to file when RawContent is empty", + rawContent: "", + content: "# file content", + want: "# file content", + }, + { + name: "file not found when RawContent empty and path invalid", + rawContent: "", + path: "/nonexistent/file.md", + wantAnyErr: true, + }, + { + name: "write error", + rawContent: "data", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.path + if tt.content != "" { + path = filepath.Join(t.TempDir(), "page.md") + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + r := &Renderer{w: w} + err := r.renderRaw(&Page{RawContent: tt.rawContent, Path: path}) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + if tt.wantAnyErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 6b5a0b6..5f1100a 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -1,16 +1,11 @@ package render import ( - "fmt" "io" - "os" - "strings" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/logger" - "github.com/TheRootDaemon/tlgc/pathutil" "github.com/TheRootDaemon/tlgc/termcolor" - "github.com/TheRootDaemon/tlgc/text" ) // Renderer renders parsed tldr pages to a writer @@ -80,6 +75,8 @@ func New(w io.Writer, options ...RenderOption) *Renderer { option(r) } + logger.Debug("useColor=%t", r.useColor) + return r } @@ -88,189 +85,49 @@ func New(w io.Writer, options ...RenderOption) *Renderer { // Nil pages are silently ignored. func (r *Renderer) Render(platform string, p *Page) error { if p == nil { + logger.Trace("nil page, skipped") return nil } - if r.output.EditLink { - if url := buildEditURL(p.Path, p.URL); url != "" { - if err := r.renderEditLink(r.w, url); err != nil { - return err - } - } - } - - if r.output.RawMarkdown { - return r.renderRaw(p) - } - - if r.output.ShowTitle && p.Title != "" { - title := p.Title - if r.output.PlatformTitle && platform != "" { - title = platform + " (" + p.Title + ")" - } - - if err := r.renderTitle(r.w, title); err != nil { - return err - } - - _, err := io.WriteString(r.w, "\n\n") - if err != nil { - return nil - } - } - - if err := r.renderDescriptions(r.w, p.Description, p.URL); err != nil { - return err - } - - for i, ex := range p.Examples { - if i > 0 && !r.output.Compact { - _, err := io.WriteString(r.w, "\n") - if err != nil { - return err - } - } + logger.Debug( + "title=%q platform=%q raw=%t compact=%t edit=%t descs=%d examples=%d", + p.Title, + platform, + r.output.RawMarkdown, + r.output.Compact, + r.output.EditLink, + len(p.Description), + len(p.Examples), + ) - if err := r.renderExample(r.w, ex); err != nil { + if !r.output.Compact { + if err := r.writeNewline(); err != nil { return err } } - return nil -} - -// renderEditLink writes the edit URL to w, with a trailing newline -// unless output is in compact mode. -func (r *Renderer) renderEditLink(w io.Writer, url string) error { - logger.Info("edit this page on GitHub") - _, err := io.WriteString(w, url) - if err != nil { + if err := r.renderPageEditLink(p); err != nil { return err } - if !r.output.Compact { - _, err := io.WriteString(w, "\n") - return err + if r.output.RawMarkdown { + return r.renderRaw(p) } - return nil -} - -// renderRaw reads the raw markdown file at p.Path and -// writes it to the Renderer's writer. -func (r *Renderer) renderRaw(p *Page) error { - data, err := os.ReadFile(p.Path) - if err != nil { + if err := r.renderPageTitle(platform, p); err != nil { return err } - _, err = r.w.Write(data) - return err -} - -// renderTitle writes the page title, -// styled with r.style.Title and -// indented by r.indent.Title spaces. -func (r *Renderer) renderTitle(w io.Writer, title string) error { - indent := strings.Repeat(" ", r.indent.Title) - - _, err := io.WriteString( - w, - r.applyStyle( - r.style.Title, - r.wrapText(title, indent), - ), - ) - return err -} - -// renderDescriptionLine writes one description line, -// styled with r.style.Description, indented, -// and followed by a newline. -func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error { - _, err := io.WriteString( - w, - r.applyStyle( - r.style.Description, - r.wrapText(text, indent), - ), - ) - if err != nil { + if err := r.renderDescriptions(r.w, p.Description, p.URL); err != nil { return err } - _, err = io.WriteString(w, "\n") - return err -} - -// renderDescriptions writes all description lines -// followed by the "More information" URL (if set), -// each indented by r.indent.Description. -// Writes a trailing blank line after descriptions. -func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) error { - if len(descs) == 0 && url == "" { - return nil - } - - indent := strings.Repeat(" ", r.indent.Description) - - for _, d := range descs { - if err := r.renderDescriptionLine(w, d, indent); err != nil { - return err - } - } - - if url != "" { - if err := r.renderDescriptionLine(w, "More information: "+url+".", indent); err != nil { - return err - } - } - - _, err := io.WriteString(w, "\n") - return err -} - -// renderBulletLine writes one bullet item line, -// styled with r.style.Bullet and indented. -// No trailing newline is added. -func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error { - _, err := io.WriteString( - w, - r.applyStyle( - r.style.Bullet, - r.wrapText(text, indent), - ), - ) - - return err -} - -// renderExample writes one example, -// a bullet line for the description (prefixed with ExamplePrefix when ShowHyphens is set) -// followed by the styled command text on the next line. -// In compact mode the blank line between bullet and command is omitted. -func (r *Renderer) renderExample(w io.Writer, ex Example) error { - indent := strings.Repeat(" ", r.indent.Bullet) - desc := ex.Description - - if r.output.ShowHyphens { - desc = r.output.ExamplePrefix + desc - } - - if err := r.renderBulletLine(w, desc, indent); err != nil { + if err := r.renderExamples(r.w, p.Examples); err != nil { return err } if !r.output.Compact { - _, err := io.WriteString(w, "\n") - if err != nil { - return err - } - } - - if ex.Command != "" { - segments := ParseCommand(ex.Command) - if err := r.renderCommand(w, segments); err != nil { + if err := r.writeNewline(); err != nil { return err } } @@ -278,36 +135,12 @@ func (r *Renderer) renderExample(w io.Writer, ex Example) error { return nil } -// buildEditURL returns the GitHub edit URL for a tldr page. -// If url is non-empty it is returned as-is (the page has a custom source). -// Otherwise the URL is constructed from the page's file path. -func buildEditURL(path, url string) string { - if url != "" { - return url - } - - if path != "" { - page := pathutil.PageName(path) - platform := pathutil.PagePlatform(path) - return fmt.Sprintf( - "https://github.com/tldr-pages/tldr/edit/main/pages/%s/%s.md", - platform, - page, - ) - } - - return "" +func (r *Renderer) writeNewline() error { + _, err := io.WriteString(r.w, "\n") + return err } -// wrapText applies text wrapping to s and prepends indent to every line. -// Wrapping is controlled by r.output.LineLength; -// when LineLength ≤ 0 or s is empty, -// only the indent is prepended without wrapping. -func (r *Renderer) wrapText(s, indent string) string { - if r.output.LineLength <= 0 || s == "" { - return indent + s - } - - wrapped := text.Wrap(s, r.output.LineLength, indent) - return indent + wrapped +func (r *Renderer) writeBlankLine() error { + _, err := io.WriteString(r.w, "\n\n") + return err } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 8f9caf4..f773d12 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -204,120 +204,94 @@ example = 6 } func TestRender(t *testing.T) { - fullPageWant := " tar\n\n" + + t.Parallel() + + fullPageWant := "\n" + + " tar\n\n" + " archive utility.\n" + " More information: https://example.org.\n" + "\n" + - " create archive\n" + + " create archive\n\n" + " tar cf archive.tar\n" + "\n" + + " extract\n\n" + + " tar xf archive.tar\n" + + "\n" + + compactFullPageWant := "" + + " tar\n" + + " archive utility.\n" + + " More information: https://example.org.\n" + + " create archive\n" + + " tar cf archive.tar\n" + " extract\n" + " tar xf archive.tar\n" + fullPage := &Page{ + Title: "tar", + Description: []string{"archive utility."}, + URL: "https://example.org", + Examples: []Example{ + {Description: "create archive", Command: "tar cf archive.tar"}, + {Description: "extract", Command: "tar xf archive.tar"}, + }, + } + tests := []struct { - name string - platform string - renderer *Renderer - page *Page - want string - contains []string - notContains []string - wantErr string + name string + renderer *Renderer + page *Page + want string + wantErr string }{ { name: "nil page returns nil", renderer: &Renderer{}, want: "", }, - { - name: "empty page with only title", - renderer: &Renderer{output: config.OutputConfig{ShowTitle: true}, indent: config.IndentConfig{Title: 2}}, - page: &Page{Title: "tar"}, - want: " tar\n\n", - }, { name: "full page renders all sections in order", renderer: &Renderer{ output: config.OutputConfig{ShowTitle: true}, indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, }, - page: &Page{ - Title: "tar", - Description: []string{"archive utility."}, - URL: "https://example.org", - Examples: []Example{ - {Description: "create archive", Command: "tar cf archive.tar"}, - {Description: "extract", Command: "tar xf archive.tar"}, - }, - }, + page: fullPage, want: fullPageWant, }, { - name: "platform title includes platform prefix", - platform: "linux", - renderer: &Renderer{ - output: config.OutputConfig{ShowTitle: true, PlatformTitle: true}, - indent: config.IndentConfig{Title: 2}, - }, - page: &Page{Title: "tar"}, - want: " linux (tar)\n\n", - }, - { - name: "edit link rendered before title", + name: "full page compact mode", renderer: &Renderer{ - output: config.OutputConfig{EditLink: true, ShowTitle: true}, - indent: config.IndentConfig{Title: 2}, - }, - page: &Page{ - Path: "/pages/common/tar.md", - Title: "tar", - }, - contains: []string{ - "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md\n", - " tar\n\n", + output: config.OutputConfig{ShowTitle: true, Compact: true}, + indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, }, + page: fullPage, + want: compactFullPageWant, }, { - name: "raw markdown mode writes file content", + name: "raw markdown mode writes content from RawContent", renderer: &Renderer{output: config.OutputConfig{RawMarkdown: true}}, - page: &Page{}, - want: "# test page\n\n> description.\n", + page: &Page{RawContent: "# test page\n\n> description.\n"}, + want: "\n# test page\n\n> description.\n", }, { - name: "compact mode omits blank lines between examples", - renderer: &Renderer{ - output: config.OutputConfig{Compact: true}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - }, - page: &Page{ - Examples: []Example{ - {Description: "first", Command: "echo a"}, - {Description: "second", Command: "echo b"}, - }, - }, - notContains: []string{"\n\n"}, + name: "leading and trailing newlines in non-compact mode", + renderer: &Renderer{output: config.OutputConfig{ShowTitle: false}}, + page: &Page{}, + want: "\n\n", }, { - name: "title hidden when ShowTitle is false", - renderer: &Renderer{ - output: config.OutputConfig{ShowTitle: false}, - indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, - }, - page: &Page{ - Title: "tar", - Description: []string{"desc."}, - Examples: []Example{{Description: "ex", Command: "cmd"}}, - }, - notContains: []string{"tar"}, + name: "compact suppresses leading and trailing newlines", + renderer: &Renderer{output: config.OutputConfig{Compact: true}}, + page: &Page{}, + want: "", }, { - name: "write error propagates", + name: "write error on leading newline propagates", renderer: &Renderer{ w: &errorWriter{err: errors.New("write error")}, - output: config.OutputConfig{ShowTitle: true}, - indent: config.IndentConfig{Title: 2}, + output: config.OutputConfig{}, }, - page: &Page{Title: "tar"}, + page: &Page{}, wantErr: "write error", }, } @@ -329,13 +303,7 @@ func TestRender(t *testing.T) { tt.renderer.w = &buf } - if tt.renderer.output.RawMarkdown && tt.want != "" { - path := filepath.Join(t.TempDir(), "page.md") - require.NoError(t, os.WriteFile(path, []byte(tt.want), 0o644)) - tt.page.Path = path - } - - err := tt.renderer.Render(tt.platform, tt.page) + err := tt.renderer.Render("", tt.page) if tt.wantErr != "" { assert.ErrorContains(t, err, tt.wantErr) @@ -343,577 +311,12 @@ func TestRender(t *testing.T) { } require.NoError(t, err) - got := buf.String() if tt.want != "" { - assert.Equal(t, tt.want, got) - } - for _, s := range tt.contains { - assert.Contains(t, got, s) - } - for _, s := range tt.notContains { - assert.NotContains(t, got, s) - } - }) - } -} - -func TestRenderEditLink(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - compact bool - url string - writer io.Writer - want string - wantErr string - }{ - { - name: "non-compact adds newline", - compact: false, - url: "https://example.com", - want: "https://example.com\n", - }, - { - name: "compact omits newline", - compact: true, - url: "https://example.com", - want: "https://example.com", - }, - { - name: "write error", - compact: false, - url: "url", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{ - output: config.OutputConfig{Compact: tt.compact}, - } - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderEditLink(w, tt.url) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestRenderRaw(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - content string - writer io.Writer - want string - wantErr string - wantAnyErr bool - }{ - { - name: "writes file content", - content: "# hello", - want: "# hello", - }, - { - name: "file not found", - wantAnyErr: true, - }, - { - name: "write error", - content: "data", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := "/nonexistent/file.md" - if tt.content != "" { - path = filepath.Join(t.TempDir(), "page.md") - require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) - } - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - r := &Renderer{w: w} - err := r.renderRaw(&Page{Path: path}) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - if tt.wantAnyErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestRenderTitle(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - title string - indent int - useColor bool - style config.OutputStyle - writer io.Writer - want string - wantErr string - }{ - { - name: "title with indent", - title: "tar", - indent: 2, - want: " tar", - }, - { - name: "zero indent", - title: "tar", - indent: 0, - want: "tar", - }, - { - name: "colorized title", - title: "tar", - indent: 2, - useColor: true, - style: config.OutputStyle{ - Bold: true, - Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, - }, - }, - { - name: "write error", - title: "tar", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{ - useColor: tt.useColor, - style: config.StyleConfig{Title: tt.style}, - indent: config.IndentConfig{Title: tt.indent}, - } - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderTitle(w, tt.title) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - got := buf.String() - - if tt.useColor { - assert.Contains(t, got, " tar") - assert.Contains(t, got, "\x1b[") - assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + assert.Equal(t, tt.want, buf.String()) } else { - assert.Equal(t, tt.want, got) - } - }) - } -} - -func TestRenderDescriptionLine(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - text string - indent string - writer io.Writer - want string - wantErr string - }{ - { - name: "writes text with trailing newline", - text: "hello", - indent: " ", - want: " hello\n", - }, - { - name: "write error", - text: "hello", - indent: " ", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{} - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderDescriptionLine(w, tt.text, tt.indent) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestRenderDescriptions(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - descs []string - url string - writer io.Writer - want string - wantErr string - }{ - { - name: "no descriptions and no url returns nil", - descs: nil, - url: "", - want: "", - }, - { - name: "single description", - descs: []string{"hello"}, - want: " hello\n\n", - }, - { - name: "multiple descriptions", - descs: []string{"first", "second"}, - want: " first\n second\n\n", - }, - { - name: "description with URL", - descs: []string{"hello"}, - url: "https://example.org", - want: " hello\n More information: https://example.org.\n\n", - }, - { - name: "URL only no descriptions", - url: "https://example.org", - want: " More information: https://example.org.\n\n", - }, - { - name: "write error", - descs: []string{"hello"}, - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{ - indent: config.IndentConfig{Description: 2}, - } - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderDescriptions(w, tt.descs, tt.url) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestRenderBulletLine(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - text string - indent string - writer io.Writer - want string - wantErr string - }{ - { - name: "writes text without trailing newline", - text: "hello", - indent: " ", - want: " hello", - }, - { - name: "write error", - text: "hello", - indent: " ", - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{} - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderBulletLine(w, tt.text, tt.indent) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestRenderExample(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - ex Example - indent config.IndentConfig - output config.OutputConfig - writer io.Writer - want string - wantErr string - }{ - { - name: "description and command non-compact", - ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - want: " create archive\n tar cf archive.tar\n", - }, - { - name: "description only no command", - ex: Example{Description: "just a description"}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - want: " just a description\n", - }, - { - name: "hyphens enabled", - ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - output: config.OutputConfig{ShowHyphens: true, ExamplePrefix: "- "}, - want: " - create archive\n tar cf archive.tar\n", - }, - { - name: "compact mode no blank line", - ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - output: config.OutputConfig{Compact: true}, - want: " create archive tar cf archive.tar\n", - }, - { - name: "write error", - ex: Example{Description: "error"}, - indent: config.IndentConfig{Bullet: 2, Example: 4}, - writer: &errorWriter{err: errors.New("write error")}, - wantErr: "write error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{ - useColor: false, - output: tt.output, - indent: tt.indent, - } - - var buf strings.Builder - w := io.Writer(&buf) - if tt.writer != nil { - w = tt.writer - } - - err := r.renderExample(w, tt.ex) - - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, buf.String()) - }) - } -} - -func TestBuildEditURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - path string - url string - want string - }{ - { - name: "non-empty url returned as-is", - path: "/pages/common/tar.md", - url: "https://example.com", - want: "https://example.com", - }, - { - name: "empty url constructs from path", - path: "/pages/common/tar.md", - want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md", - }, - { - name: "linux platform extracted correctly", - path: "/pages/linux/apt.md", - want: "https://github.com/tldr-pages/tldr/edit/main/pages/linux/apt.md", - }, - { - name: "windows platform extracted correctly", - path: "/pages/windows/dir.md", - want: "https://github.com/tldr-pages/tldr/edit/main/pages/windows/dir.md", - }, - { - name: "empty path and empty url returns empty", - path: "", - url: "", - want: "", - }, - { - name: "path without .md extension adds .md", - path: "/pages/common/some-page", - want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/some-page.md", - }, - { - name: "url takes precedence over path", - path: "/pages/common/tar.md", - url: "https://custom.com/edit", - want: "https://custom.com/edit", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildEditURL(tt.path, tt.url) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestWrapText(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - lineLength int - s string - indent string - want string - }{ - { - name: "line length zero returns indent plus text", - lineLength: 0, - s: "hello world", - indent: " ", - want: " hello world", - }, - { - name: "line length negative returns indent plus text", - lineLength: -1, - s: "short", - indent: " ", - want: " short", - }, - { - name: "empty text returns indent", - lineLength: 80, - s: "", - indent: ">>", - want: ">>", - }, - { - name: "text fits within line length", - lineLength: 80, - s: "hi", - indent: " ", - want: " hi", - }, - { - name: "text wraps with indent on continuation lines", - lineLength: 12, - s: "hello world foo", - indent: "> ", - want: "> hello world\n> foo", - }, - { - name: "long word exceeds line length without splitting", - lineLength: 5, - s: "superlongword", - indent: "- ", - want: "- superlongword", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Renderer{ - output: config.OutputConfig{LineLength: tt.lineLength}, + assert.Empty(t, buf.String()) } - got := r.wrapText(tt.s, tt.indent) - assert.Equal(t, tt.want, got) }) } } diff --git a/internal/render/style.go b/internal/render/style.go index 6865906..50b37e3 100644 --- a/internal/render/style.go +++ b/internal/render/style.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/termcolor" ) @@ -14,7 +15,9 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { return t } - return termcolor.Sprint(styleString(s), t) + styled := termcolor.Sprint(styleString(s), t) + logger.Trace("len=%d styled=%t", len(t), styled != t) + return styled } // styleForSegment returns the OutputStyle @@ -22,6 +25,7 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { // Text segments use the Example style; // Placeholder and Option segments use the Placeholder style. func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { + logger.Trace("kind=%d", s.Kind) switch s.Kind { case Text: return r.style.Example @@ -62,5 +66,7 @@ func styleString(s config.OutputStyle) string { parts = append(parts, "on_"+string(s.Background.Named)) } - return strings.Join(parts, " ") + result := strings.Join(parts, " ") + logger.Trace("result=%q", result) + return result } diff --git a/internal/render/title.go b/internal/render/title.go new file mode 100644 index 0000000..160c864 --- /dev/null +++ b/internal/render/title.go @@ -0,0 +1,55 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/logger" +) + +// renderTitle writes the page title, +// styled with r.style.Title and +// indented by r.indent.Title spaces. +func (r *Renderer) renderTitle(w io.Writer, title string) error { + indent := strings.Repeat(" ", r.indent.Title) + logger.Trace("title=%q indent=%d", title, r.indent.Title) + + _, err := io.WriteString( + w, + r.applyStyle( + r.style.Title, + r.wrapText(title, indent), + ), + ) + return err +} + +// renderPageTitle renders the page title section for p. +// +// If title rendering is disabled +// or the page has no title, it does nothing. +// When PlatformTitle is enabled, +// the title is prefixed with "/". +// After rendering the title, +// it writes the appropriate trailing newline +// based on the configured output mode. +func (r *Renderer) renderPageTitle(platform string, p *Page) error { + if !r.output.ShowTitle || p.Title == "" { + return nil + } + + title := p.Title + if r.output.PlatformTitle && platform != "" { + title = platform + "/" + title + } + + if err := r.renderTitle(r.w, title); err != nil { + return err + } + + if r.output.Compact { + return r.writeNewline() + } + + return r.writeBlankLine() +} diff --git a/internal/render/title_test.go b/internal/render/title_test.go new file mode 100644 index 0000000..be4f81e --- /dev/null +++ b/internal/render/title_test.go @@ -0,0 +1,184 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + indent int + useColor bool + style config.OutputStyle + writer io.Writer + want string + wantErr string + }{ + { + name: "title with indent", + title: "tar", + indent: 2, + want: " tar", + }, + { + name: "zero indent", + title: "tar", + indent: 0, + want: "tar", + }, + { + name: "colorized title", + title: "tar", + indent: 2, + useColor: true, + style: config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + }, + }, + { + name: "write error", + title: "tar", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + style: config.StyleConfig{Title: tt.style}, + indent: config.IndentConfig{Title: tt.indent}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderTitle(w, tt.title) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + got := buf.String() + + if tt.useColor { + assert.Contains(t, got, " tar") + assert.Contains(t, got, "\x1b[") + assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestRenderPageTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + platform string + page *Page + showTitle bool + platformTitle bool + compact bool + writer io.Writer + want string + wantErr string + }{ + { + name: "show title disabled", + showTitle: false, + page: &Page{Title: "tar"}, + want: "", + }, + { + name: "empty title", + showTitle: true, + page: &Page{Title: ""}, + want: "", + }, + { + name: "normal title non-compact", + showTitle: true, + page: &Page{Title: "tar"}, + want: " tar\n\n", + }, + { + name: "normal title compact", + showTitle: true, + compact: true, + page: &Page{Title: "tar"}, + want: " tar\n", + }, + { + name: "platform prefix", + showTitle: true, + platformTitle: true, + platform: "linux", + page: &Page{Title: "tar"}, + want: " linux/tar\n\n", + }, + { + name: "platform title enabled no platform", + showTitle: true, + platformTitle: true, + platform: "", + page: &Page{Title: "tar"}, + want: " tar\n\n", + }, + { + name: "write error", + showTitle: true, + page: &Page{Title: "tar"}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := &Renderer{ + w: &buf, + output: config.OutputConfig{ + ShowTitle: tt.showTitle, + PlatformTitle: tt.platformTitle, + Compact: tt.compact, + }, + indent: config.IndentConfig{Title: 2}, + } + + if tt.writer != nil { + r.w = tt.writer + } + + err := r.renderPageTitle(tt.platform, tt.page) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/internal/render/wrap.go b/internal/render/wrap.go new file mode 100644 index 0000000..b1dbaa6 --- /dev/null +++ b/internal/render/wrap.go @@ -0,0 +1,30 @@ +package render + +import ( + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/text" +) + +// wrapText applies text wrapping to s and prepends indent to every line. +// Wrapping is controlled by r.output.LineLength; +// when LineLength ≤ 0 or s is empty, +// only the indent is prepended without wrapping. +func (r *Renderer) wrapText(s, indent string) string { + if r.output.LineLength <= 0 || s == "" { + logger.Trace( + "no wrap (len=%d ll=%d)", + len(s), + r.output.LineLength, + ) + return indent + s + } + + wrapped := text.Wrap(s, r.output.LineLength, indent) + logger.Trace( + "input=%d ll=%d -> %d chars", + len(s), + r.output.LineLength, + len(wrapped), + ) + return indent + wrapped +} diff --git a/internal/render/wrap_test.go b/internal/render/wrap_test.go new file mode 100644 index 0000000..72be9f3 --- /dev/null +++ b/internal/render/wrap_test.go @@ -0,0 +1,73 @@ +package render + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestWrapText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lineLength int + s string + indent string + want string + }{ + { + name: "line length zero returns indent plus text", + lineLength: 0, + s: "hello world", + indent: " ", + want: " hello world", + }, + { + name: "line length negative returns indent plus text", + lineLength: -1, + s: "short", + indent: " ", + want: " short", + }, + { + name: "empty text returns indent", + lineLength: 80, + s: "", + indent: ">>", + want: ">>", + }, + { + name: "text fits within line length", + lineLength: 80, + s: "hi", + indent: " ", + want: " hi", + }, + { + name: "text wraps with indent on continuation lines", + lineLength: 12, + s: "hello world foo", + indent: "> ", + want: "> hello world\n> foo", + }, + { + name: "long word exceeds line length without splitting", + lineLength: 5, + s: "superlongword", + indent: "- ", + want: "- superlongword", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{LineLength: tt.lineLength}, + } + got := r.wrapText(tt.s, tt.indent) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/upstream/checksum.go b/internal/upstream/checksum.go index 1a20748..97213bc 100644 --- a/internal/upstream/checksum.go +++ b/internal/upstream/checksum.go @@ -5,6 +5,8 @@ import ( "fmt" "hash" "strings" + + "github.com/TheRootDaemon/tlgc/logger" ) // verifySHA256hex verifies got against an expected SHA256 checksum. @@ -12,6 +14,7 @@ import ( // Expected may be either a raw 64-character hexadecimal SHA256 hash // or a checksum-file entry in the form " ". func verifySHA256hex(got, expected string) error { + logger.Trace("verifying sha256: expected %.16s...", expected) expected = strings.TrimSpace(expected) if expected == "" { return nil @@ -64,6 +67,7 @@ func verifySHA256Hash(h hash.Hash, expected string) error { // Returns the hash and filename. // Returns an error for empty lines. func ParseChecksum(line string) (hash, filename string, err error) { + logger.Trace("parsing checksum: %s", line) line = strings.TrimSpace(line) if line == "" { return "", "", fmt.Errorf("empty checksum line") diff --git a/internal/upstream/http.go b/internal/upstream/http.go index 98cdeb4..a5cabd4 100644 --- a/internal/upstream/http.go +++ b/internal/upstream/http.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + + "github.com/TheRootDaemon/tlgc/logger" ) // newRequest creates an HTTP GET request with the configured User-Agent. @@ -38,6 +40,7 @@ func (c *Client) send(req *http.Request) (*http.Response, error) { // if resp does not contain a successful 2xx status code. // The response body is closed on failure. func (c *Client) validateResponse(resp *http.Response) error { + logger.Trace("response: %d", resp.StatusCode) if resp.StatusCode < 200 || resp.StatusCode >= 300 { _ = resp.Body.Close() return fmt.Errorf("unexpected status: %d", resp.StatusCode) @@ -74,6 +77,7 @@ func (c *Client) wrapBody( // execute performs an HTTP GET request, validates the response, and wraps // the response body with any configured limits and progress reporting. func (c *Client) execute(ctx context.Context, url string) (*http.Response, error) { + logger.Debug("GET %s", url) req, err := c.newRequest(ctx, url) if err != nil { return nil, err diff --git a/main.go b/main.go index 330eb1a..8237037 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,28 @@ package main import ( - "fmt" + "os" - "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/app" + "github.com/TheRootDaemon/tlgc/logger" ) func main() { - fmt.Println(config.DefaultConfig()) + cli, err := cmd.Parse() + if err != nil { + logger.Error("%w", err) + os.Exit(1) + } + + logger.SetDefault( + logger.New( + cli.Quiet, + cli.Verbose, + ), + ) + + os.Exit( + app.New().Run(cli), + ) }