From e0ac89e11fb7ffe521aaaf0679890b147c308b05 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 10:15:06 +0530 Subject: [PATCH 01/58] docs(cmd) --- cmd/cli.go | 4 +++- cmd/help.go | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/cli.go b/cmd/cli.go index abd3337..d1ebe95 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -46,7 +46,7 @@ type CLI struct { // ShowHelp requests printing the help text. ShowHelp bool - // Options + // options // Platform overrides the platform used for page lookup. Platform string @@ -69,11 +69,13 @@ type CLI struct { // Compact strips empty lines from output. Compact bool + // NoCompact overrides --compact, preserving empty lines in output. NoCompact bool // Raw prints pages in raw markdown. Raw bool + // NoRaw overrides --raw, rendering pages instead of printing raw content. NoRaw bool // Quiet suppresses informational and warning messages. diff --git a/cmd/help.go b/cmd/help.go index 413b4d0..c5c6622 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -8,12 +8,14 @@ import ( "github.com/TheRootDaemon/tlgc/version" ) +// help prints the full help text: usage, flags, and the footer. func help() { printUsage() printFlags() printFooter() } +// printUsage prints the version, usage line, and argument descriptions. func printUsage() { fmt.Printf( "tlgc %s (implementing client specification v2.3)\n\n", @@ -30,6 +32,7 @@ func printUsage() { fmt.Printf(" [PAGE]... The tldr page to show\n\n") } +// printFlags prints the aligned table of all available command-line options. func printFlags() { type flagEntry struct { short string @@ -192,10 +195,13 @@ func printFlags() { } } +// printFooter prints the project URL footer. func printFooter() { fmt.Printf("\nSee https://github.com/TheRootDaemon/tlgc for more information.\n") } +// updateColumnWidths updates the running maximum column widths +// for the short and long flag columns. func updateColumnWidths( maxShort, maxLong *int, @@ -220,6 +226,7 @@ func updateColumnWidths( *maxLong = max(*maxLong, longWidth) } +// printFlag prints a single colorized and aligned row of the flags table. func printFlag( maxShort, maxLong int, From 6bdff050dc79609884cad863ac9e9eb936883ff5 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 10:15:37 +0530 Subject: [PATCH 02/58] tests(cmd) --- cmd/list_values_test.go | 139 +++++++++ cmd/parse_test.go | 626 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 765 insertions(+) create mode 100644 cmd/list_values_test.go create mode 100644 cmd/parse_test.go diff --git a/cmd/list_values_test.go b/cmd/list_values_test.go new file mode 100644 index 0000000..7e6eb0a --- /dev/null +++ b/cmd/list_values_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCountValueNilPointer(t *testing.T) { + t.Parallel() + + v := &countValue{} + assert.Equal(t, "0", v.String()) +} + +func TestCountValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setCount int + want string + wantBool bool + }{ + { + name: "zero_value", + want: "0", + wantBool: true, + }, + { + name: "single_increment", + setCount: 1, + want: "1", + wantBool: true, + }, + { + name: "double_increment", + setCount: 2, + want: "2", + wantBool: true, + }, + { + name: "triple_increment", + setCount: 3, + want: "3", + wantBool: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var count uint8 + v := &countValue{count: &count} + + for range tt.setCount { + require.NoError(t, v.Set("")) + } + + assert.Equal(t, tt.want, v.String()) + assert.Equal(t, tt.wantBool, v.IsBoolFlag()) + }) + } +} + +func TestStringListValueNilPointer(t *testing.T) { + t.Parallel() + + v := &stringListValue{} + assert.Equal(t, "", v.String()) +} + +func TestStringListValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs []string + want string + wantS []string + }{ + { + name: "empty", + inputs: nil, + want: "", + wantS: nil, + }, + { + name: "single_value", + inputs: []string{"en"}, + want: "en", + wantS: []string{"en"}, + }, + { + name: "comma_separated", + inputs: []string{"de,pl"}, + want: "de,pl", + wantS: []string{"de", "pl"}, + }, + { + name: "whitespace_trimmed", + inputs: []string{" de , pl "}, + want: "de,pl", + wantS: []string{"de", "pl"}, + }, + { + name: "empty_parts_skipped", + inputs: []string{",en,,de,"}, + want: "en,de", + wantS: []string{"en", "de"}, + }, + { + name: "multiple_calls", + inputs: []string{"en", "de"}, + want: "en,de", + wantS: []string{"en", "de"}, + }, + { + name: "mixed_comma_and_repeated", + inputs: []string{"en,de", "fr"}, + want: "en,de,fr", + wantS: []string{"en", "de", "fr"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var values []string + v := &stringListValue{values: &values} + + for _, input := range tt.inputs { + require.NoError(t, v.Set(input)) + } + + assert.Equal(t, tt.want, v.String()) + assert.Equal(t, tt.wantS, values) + }) + } +} diff --git a/cmd/parse_test.go b/cmd/parse_test.go new file mode 100644 index 0000000..b70b2d4 --- /dev/null +++ b/cmd/parse_test.go @@ -0,0 +1,626 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + check func(t *testing.T, cli *CLI) + wantErr bool + }{ + // operations (short forms) + { + name: "update_short", + args: []string{"-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Update) + }, + }, + { + name: "list_short", + args: []string{"-l"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.List) + }, + }, + { + name: "list_all_short", + args: []string{"-a"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListAll) + }, + }, + { + name: "search_short", + args: []string{"-s", "ngi"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "ngi", cli.Search) + }, + }, + { + name: "info_short", + args: []string{"-i"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Info) + }, + }, + { + name: "render_short", + args: []string{"-r", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "file.md", cli.Render) + }, + }, + + // operations (long forms) + { + name: "update_long", + args: []string{"--update"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Update) + }, + }, + { + name: "list_long", + args: []string{"--list"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.List) + }, + }, + { + name: "list_all_long", + args: []string{"--list-all"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListAll) + }, + }, + { + name: "search_long", + args: []string{"--search", "nginx"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "nginx", cli.Search) + }, + }, + { + name: "info_long", + args: []string{"--info"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Info) + }, + }, + { + name: "render_long", + args: []string{"--render", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "file.md", cli.Render) + }, + }, + { + name: "list_platforms", + args: []string{"--list-platforms"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListPlatforms) + }, + }, + { + name: "list_languages", + args: []string{"--list-languages"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ListLanguages) + }, + }, + { + name: "clean_cache", + args: []string{"--clean-cache"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.CleanCache) + }, + }, + { + name: "gen_config", + args: []string{"--gen-config"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.GenConfig) + }, + }, + { + name: "config_path", + args: []string{"--config-path"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ConfigPath) + }, + }, + + // positional args + { + name: "single_page", + args: []string{"tar"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar"}, cli.Page) + }, + }, + { + name: "multiple_pages", + args: []string{"tar", "git"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar", "git"}, cli.Page) + }, + }, + + // options + { + name: "platform_short", + args: []string{"-p", "linux", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "linux", cli.Platform) + }, + }, + { + name: "platform_long", + args: []string{"--platform", "osx", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "osx", cli.Platform) + }, + }, + { + name: "language_single", + args: []string{"-L", "en", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en"}, cli.Languages) + }, + }, + { + name: "language_repeat", + args: []string{"-L", "en", "-L", "de", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en", "de"}, cli.Languages) + }, + }, + { + name: "language_comma", + args: []string{"-L", "en,de", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"en", "de"}, cli.Languages) + }, + }, + { + name: "language_long", + args: []string{"--language", "fr", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"fr"}, cli.Languages) + }, + }, + { + name: "offline_short", + args: []string{"-o", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Offline) + }, + }, + { + name: "offline_long", + args: []string{"--offline", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Offline) + }, + }, + { + name: "compact_short", + args: []string{"-c", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Compact) + }, + }, + { + name: "compact_long", + args: []string{"--compact", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Compact) + }, + }, + { + name: "no_compact", + args: []string{"--no-compact", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.NoCompact) + }, + }, + { + name: "raw_short", + args: []string{"-R", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Raw) + }, + }, + { + name: "raw_long", + args: []string{"--raw", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Raw) + }, + }, + { + name: "no_raw", + args: []string{"--no-raw", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.NoRaw) + }, + }, + { + name: "quiet_short", + args: []string{"-q", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Quiet) + }, + }, + { + name: "quiet_long", + args: []string{"--quiet", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Quiet) + }, + }, + { + name: "verbose_single", + args: []string{"--verbose", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, uint8(1), cli.Verbose) + }, + }, + { + name: "verbose_double", + args: []string{"--verbose", "--verbose", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, uint8(2), cli.Verbose) + }, + }, + { + name: "color_auto", + args: []string{"--color", "auto", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "auto", cli.Color) + }, + }, + { + name: "color_always", + args: []string{"--color", "always", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "always", cli.Color) + }, + }, + { + name: "color_never", + args: []string{"--color", "never", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "never", cli.Color) + }, + }, + { + name: "color_default", + args: []string{"-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "auto", cli.Color) + }, + }, + { + name: "config_path_option", + args: []string{"--config", "/tmp/cfg", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "/tmp/cfg", cli.Config) + }, + }, + { + name: "edit", + args: []string{"--edit", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Edit) + }, + }, + { + name: "short_options", + args: []string{"--short-options", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShortOptions) + }, + }, + { + name: "long_options", + args: []string{"--long-options", "-u"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.LongOptions) + }, + }, + + // combined + { + name: "search_with_platform_and_language", + args: []string{"-s", "ngi", "-p", "linux", "-L", "en"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "ngi", cli.Search) + assert.Equal(t, "linux", cli.Platform) + assert.Equal(t, []string{"en"}, cli.Languages) + }, + }, + { + name: "page_with_all_options", + args: []string{"tar", "-p", "linux", "-L", "en", "-o", "-c", "-q"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"tar"}, cli.Page) + assert.Equal(t, "linux", cli.Platform) + assert.Equal(t, []string{"en"}, cli.Languages) + assert.True(t, cli.Offline) + assert.True(t, cli.Compact) + assert.True(t, cli.Quiet) + }, + }, + + // special operations + { + name: "version", + args: []string{"-v"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowVersion) + }, + }, + { + name: "version_long", + args: []string{"--version"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowVersion) + }, + }, + { + name: "help", + args: []string{"-h"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowHelp) + }, + }, + { + name: "help_long", + args: []string{"--help"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.ShowHelp) + }, + }, + { + name: "no_args_prints_help", + args: []string{}, + wantErr: false, + }, + + // error cases + { + name: "two_operations", + args: []string{"-u", "-l"}, + wantErr: true, + }, + { + name: "three_operations", + args: []string{"-u", "-l", "-a"}, + wantErr: true, + }, + { + name: "invalid_color", + args: []string{"--color", "invalid", "-u"}, + wantErr: true, + }, + { + name: "unknown_flag", + args: []string{"--bogus"}, + wantErr: true, + }, + { + name: "unknown_short_flag", + args: []string{"-x"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, err := parse(tt.args) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, cli) + if tt.check != nil { + tt.check(t, cli) + } + }) + } +} + +func TestOperationCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want int + }{ + { + name: "none", + cli: CLI{}, + want: 0, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: 1, + }, + { + name: "update", + cli: CLI{Update: true}, + want: 1, + }, + { + name: "list", + cli: CLI{List: true}, + want: 1, + }, + { + name: "list_all", + cli: CLI{ListAll: true}, + want: 1, + }, + { + name: "search", + cli: CLI{Search: "ngi"}, + want: 1, + }, + { + name: "list_platforms", + cli: CLI{ListPlatforms: true}, + want: 1, + }, + { + name: "list_languages", + cli: CLI{ListLanguages: true}, + want: 1, + }, + { + name: "info", + cli: CLI{Info: true}, + want: 1, + }, + { + name: "render", + cli: CLI{Render: "file.md"}, + want: 1, + }, + { + name: "clean_cache", + cli: CLI{CleanCache: true}, + want: 1, + }, + { + name: "gen_config", + cli: CLI{GenConfig: true}, + want: 1, + }, + { + name: "config_path", + cli: CLI{ConfigPath: true}, + want: 1, + }, + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + want: 2, + }, + { + name: "all_operations", + cli: CLI{ + Page: []string{"tar"}, + Update: true, + List: true, + ListAll: true, + Search: "ngi", + ListPlatforms: true, + ListLanguages: true, + Info: true, + Render: "file.md", + CleanCache: true, + GenConfig: true, + ConfigPath: true, + }, + want: 12, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cli.operationCount() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestReorderFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "empty", + args: []string{}, + want: nil, + }, + { + name: "only_positional", + args: []string{"page1", "page2"}, + want: []string{"page1", "page2"}, + }, + { + name: "only_flags", + args: []string{"-u"}, + want: []string{"-u"}, + }, + { + name: "flags_before_positional", + args: []string{"-u", "page1"}, + want: []string{"-u", "page1"}, + }, + { + name: "flag_after_positional", + args: []string{"page1", "-u"}, + want: []string{"-u", "page1"}, + }, + { + name: "value_flag_after_positional", + args: []string{"page1", "-p", "linux"}, + want: []string{"-p", "linux", "page1"}, + }, + { + name: "long_flag_after_positional", + args: []string{"page1", "--update"}, + want: []string{"--update", "page1"}, + }, + { + name: "multiple_flags_after_positionals", + args: []string{"page1", "-u", "-p", "linux", "-o"}, + want: []string{"-u", "-p", "linux", "-o", "page1"}, + }, + { + name: "equals_syntax_stays_in_place", + args: []string{"-p=linux", "page1"}, + want: []string{"-p=linux", "page1"}, + }, + { + name: "mixed_positional_and_flags", + args: []string{"page1", "-u", "page2", "-p", "linux"}, + want: []string{"-u", "-p", "linux", "page1", "page2"}, + }, + { + name: "all_value_flags", + args: []string{"page1", "-L", "en", "-s", "foo", "-r", "file.md", "--color", "always", "--config", "/tmp/cfg"}, + want: []string{"-L", "en", "-s", "foo", "-r", "file.md", "--color", "always", "--config", "/tmp/cfg", "page1"}, + }, + { + name: "search_flag_after_positional", + args: []string{"tar", "-s", "ngi"}, + want: []string{"-s", "ngi", "tar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reorderFlags(tt.args) + assert.Equal(t, tt.want, got) + }) + } +} From 7c3eb5aeb6d9ebe3a7285e2042877432b10c1d98 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 10:53:20 +0530 Subject: [PATCH 03/58] tests(app): Add tests for search --- internal/app/search_test.go | 188 ++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 internal/app/search_test.go diff --git a/internal/app/search_test.go b/internal/app/search_test.go new file mode 100644 index 0000000..b0e3fd8 --- /dev/null +++ b/internal/app/search_test.go @@ -0,0 +1,188 @@ +package app + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/stretchr/testify/assert" +) + +func TestColumnWidths(t *testing.T) { + t.Parallel() + + languageHeader := len("Language") + platformHeader := len("Platform") + pageHeader := len("Page") + + tests := []struct { + name string + results []cache.SearchResult + wantLang int + wantPlat int + wantPage int + }{ + { + name: "empty_results_uses_header_widths", + results: []cache.SearchResult{}, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "header_wins_over_short_values", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "long_page_widens_page_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "very-long-page-name"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: len("very-long-page-name"), + }, + { + name: "long_language_widens_language_column", + results: []cache.SearchResult{ + {Language: "pt_BR", Platform: "common", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "long_platform_widens_platform_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "android", Page: "tar"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "picks_max_across_all_rows", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "apt"}, + {Language: "fr_FR", Platform: "android", Page: "very-long-page-name"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: len("very-long-page-name"), + }, + { + name: "multiple_languages_picks_widest", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + {Language: "de", Platform: "common", Page: "git"}, + {Language: "pt_BR", Platform: "common", Page: "ls"}, + }, + wantLang: languageHeader, + wantPlat: platformHeader, + wantPage: pageHeader, + }, + { + name: "value_exceeding_header_widens_column", + results: []cache.SearchResult{ + {Language: "en", Platform: "common", Page: "tar"}, + {Language: "en_AUSTRALIA", Platform: "common", Page: "git"}, + }, + wantLang: len("en_AUSTRALIA"), + wantPlat: platformHeader, + wantPage: pageHeader, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLang, gotPlat, gotPage := columnWidths(tt.results) + assert.Equal(t, tt.wantLang, gotLang) + assert.Equal(t, tt.wantPlat, gotPlat) + assert.Equal(t, tt.wantPage, gotPage) + }) + } +} + +func TestHighlightQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + query string + }{ + { + name: "empty_query", + text: "nginx", + query: "", + }, + { + name: "no_match", + text: "nginx", + query: "xyz", + }, + { + name: "single_match_at_start", + text: "nginx", + query: "ngi", + }, + { + name: "single_match_at_end", + text: "nginx", + query: "inx", + }, + { + name: "single_match_in_middle", + text: "git-commit", + query: "com", + }, + { + name: "multiple_matches", + text: "nginx nginx", + query: "ng", + }, + { + name: "full_match", + text: "git", + query: "git", + }, + { + name: "case_insensitive_match", + text: "Nginx", + query: "ngi", + }, + { + name: "mixed_case_query", + text: "nginx", + query: "NgI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := highlightQuery(tt.text, tt.query) + assert.Equal(t, tt.text, stripANSI(got), "visible text must be preserved") + }) + } +} + +// stripANSI removes ANSI escape sequences from s. +func stripANSI(s string) string { + var out []byte + inEscape := false + for i := 0; i < len(s); i++ { + switch { + case s[i] == '\x1b': + inEscape = true + case inEscape && s[i] == 'm': + inEscape = false + case !inEscape: + out = append(out, s[i]) + } + } + return string(out) +} From db15e9ba16a0f1cf99a60c82dd35e64812d6514a Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 10:57:47 +0530 Subject: [PATCH 04/58] tests(app): Add tests for info --- internal/app/info_test.go | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/app/info_test.go diff --git a/internal/app/info_test.go b/internal/app/info_test.go new file mode 100644 index 0000000..795ca38 --- /dev/null +++ b/internal/app/info_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/stretchr/testify/assert" +) + +func TestFormatPlatformBreakdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + platforms []cache.PlatformInfo + want string + }{ + { + name: "empty", + platforms: nil, + want: "", + }, + { + name: "single_platform", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + }, + want: " (common: 3000)", + }, + { + name: "multiple_platforms", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + {Name: "linux", Pages: 2500}, + }, + want: " (common: 3000, linux: 2500)", + }, + { + name: "three_platforms", + platforms: []cache.PlatformInfo{ + {Name: "common", Pages: 3000}, + {Name: "linux", Pages: 2500}, + {Name: "osx", Pages: 2100}, + }, + want: " (common: 3000, linux: 2500, osx: 2100)", + }, + { + name: "zero_pages", + platforms: []cache.PlatformInfo{ + {Name: "windows", Pages: 0}, + }, + want: " (windows: 0)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripANSI(formatPlatformBreakdown(tt.platforms)) + assert.Equal(t, tt.want, got) + }) + } +} From de717d863f22d8a5e8cfe5b73ede0f975e49ad0a Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 11:44:33 +0530 Subject: [PATCH 05/58] refactor(app): Simplify funcs, add tests --- internal/app/page.go | 101 +++++++------- internal/app/page_test.go | 268 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+), 54 deletions(-) create mode 100644 internal/app/page_test.go diff --git a/internal/app/page.go b/internal/app/page.go index 5fffd1e..232e7a5 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -50,34 +50,14 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { return 1 } - root, err := os.OpenRoot( - filepath.Dir(pagePath), - ) + page, err := loadPage(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) + if err := a.renderPage(cli, renderPlatform, page); err != nil { + logger.Error("failed to render: %v", err) return 1 } return 0 @@ -87,44 +67,28 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { // 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), - ) + page, err := loadPage(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 { + if err := a.renderPage(cli, "", page); err != nil { logger.Error("failed to render: %v", err) return 1 } return 0 } +func (a *App) renderPage( + cli *cmd.CLI, + platform string, + page *render.Page, +) error { + renderer := render.New(a.Stdout, a.renderOptions(cli)...) + return renderer.Render(platform, page) +} + // selectPage chooses the best matching page // and falls back to pages from other platforms // when no exact match exists. @@ -180,15 +144,17 @@ func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { } output := config.Output() - if cli.NoCompact { + switch { + case cli.NoCompact: output.Compact = false - } else if cli.Compact { + case cli.Compact: output.Compact = true } - if cli.NoRaw { + switch { + case cli.NoRaw: output.RawMarkdown = false - } else if cli.Raw { + case cli.Raw: output.RawMarkdown = true } @@ -208,3 +174,30 @@ func (a *App) renderOptions(cli *cmd.CLI) []render.RenderOption { opts = append(opts, render.WithOutput(output)) return opts } + +// loadPage reads and validates the TL;DR markdown page at path, +// parses it into a render.Page, +// and sets the page's Path and RawContent fields. +// It returns an error if the file cannot be read +// or is not a valid TLDR page. +func loadPage(path string) (*render.Page, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + + data, err := root.ReadFile(filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if err := render.Validate(string(data)); err != nil { + return nil, fmt.Errorf("invalid tldr page: %w", err) + } + + page := render.Parse(string(data)) + page.Path = path + page.RawContent = string(data) + + return page, nil +} diff --git a/internal/app/page_test.go b/internal/app/page_test.go new file mode 100644 index 0000000..4791670 --- /dev/null +++ b/internal/app/page_test.go @@ -0,0 +1,268 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/cache" + "github.com/TheRootDaemon/tlgc/internal/render" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelectPage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results *cache.FindResult + query string + platform string + wantPath string + wantPlat string + wantErr bool + wantStderr string + }{ + { + name: "exact_match", + results: &cache.FindResult{Matches: []string{"/pages.en/common/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + }, + { + name: "multiple_matches_uses_first", + results: &cache.FindResult{Matches: []string{"/pages.en/common/tar.md", "/pages.en/linux/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + }, + { + name: "fallback_only", + results: &cache.FindResult{Fallbacks: []string{"/pages.en/osx/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/osx/tar.md", + wantPlat: "osx", + wantStderr: "1. osx (tldr --platform osx tar)\n", + }, + { + name: "matches_preferred_over_fallbacks", + results: &cache.FindResult{ + Matches: []string{"/pages.en/common/tar.md"}, + Fallbacks: []string{"/pages.en/osx/tar.md"}, + }, + query: "tar", + platform: "linux", + wantPath: "/pages.en/common/tar.md", + wantPlat: "linux", + wantStderr: "1. osx (tldr --platform osx tar)\n", + }, + { + name: "no_results", + results: &cache.FindResult{}, + query: "tar", + platform: "linux", + wantErr: true, + }, + { + name: "nil_matches_and_fallbacks", + results: &cache.FindResult{}, + query: "tar", + platform: "linux", + wantErr: true, + }, + { + name: "multiple_fallbacks", + results: &cache.FindResult{Fallbacks: []string{"/pages.en/osx/tar.md", "/pages.en/windows/tar.md"}}, + query: "tar", + platform: "linux", + wantPath: "/pages.en/osx/tar.md", + wantPlat: "osx", + wantStderr: "1. osx (tldr --platform osx tar)\n2. windows (tldr --platform windows tar)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stderr bytes.Buffer + a := &App{Stderr: &stderr} + + gotPath, gotPlat, err := a.selectPage(tt.results, tt.query, tt.platform) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPath, gotPath) + assert.Equal(t, tt.wantPlat, gotPlat) + + if tt.wantStderr != "" { + assert.Equal(t, tt.wantStderr, stderr.String()) + } else { + assert.Empty(t, stderr.String()) + } + }) + } +} + +func TestRenderOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli cmd.CLI + wantLen int + }{ + { + name: "default", + cli: cmd.CLI{}, + wantLen: 2, + }, + { + name: "color_always", + cli: cmd.CLI{Color: "always"}, + wantLen: 3, + }, + { + name: "color_never", + cli: cmd.CLI{Color: "never"}, + wantLen: 3, + }, + { + name: "edit", + cli: cmd.CLI{Edit: true}, + wantLen: 2, + }, + { + name: "compact", + cli: cmd.CLI{Compact: true}, + wantLen: 2, + }, + { + name: "no_compact", + cli: cmd.CLI{NoCompact: true}, + wantLen: 2, + }, + { + name: "raw", + cli: cmd.CLI{Raw: true}, + wantLen: 2, + }, + { + name: "no_raw", + cli: cmd.CLI{NoRaw: true}, + wantLen: 2, + }, + { + name: "short_options", + cli: cmd.CLI{ShortOptions: true}, + wantLen: 2, + }, + { + name: "long_options", + cli: cmd.CLI{LongOptions: true}, + wantLen: 2, + }, + { + name: "both_options", + cli: cmd.CLI{ShortOptions: true, LongOptions: true}, + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &App{Stdout: &bytes.Buffer{}} + got := a.renderOptions(&tt.cli) + assert.Equal(t, tt.wantLen, len(got)) + }) + } +} + +func TestLoadPage(t *testing.T) { + t.Parallel() + + validPage := "# tar\n\n> archive utility.\n\n- create an archive:\n\n`tar cf archive.tar`\n" + + tests := []struct { + name string + content string + wantErr bool + check func(t *testing.T, page *render.Page, path string) + }{ + { + name: "valid_page", + content: validPage, + wantErr: false, + check: func(t *testing.T, page *render.Page, path string) { + assert.Equal(t, "tar", page.Title) + assert.Equal(t, path, page.Path) + assert.Equal(t, validPage, page.RawContent) + assert.NotEmpty(t, page.Examples) + }, + }, + { + name: "valid_page_with_url", + content: "# tar\n\n> archive utility.\n> More information: .\n\n- create:\n\n`tar cf archive.tar`\n", + wantErr: false, + check: func(t *testing.T, page *render.Page, _ string) { + assert.Equal(t, "tar", page.Title) + assert.Equal(t, "https://example.org/tar", page.URL) + }, + }, + { + name: "nonexistent_file", + content: "", + wantErr: true, + }, + { + name: "invalid_page_content", + content: "some random text\n", + wantErr: true, + }, + { + name: "empty_file", + content: "", + wantErr: false, + check: func(t *testing.T, page *render.Page, _ string) { + assert.Empty(t, page.Title) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "page.md") + + if tt.name != "nonexistent_file" { + require.NoError(t, writeTestFile(path, tt.content)) + } + + got, err := loadPage(path) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, got) + if tt.check != nil { + tt.check(t, got, path) + } + }) + } +} + +// writeTestFile creates or overwrites a test file +// at path with the given content. +func writeTestFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} From 37072b865292f0ec9022e0e7b719244753b8afb7 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 11:49:45 +0530 Subject: [PATCH 06/58] refator(app): Reduce cyclo for Run, Resolves #13 --- internal/app/app.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 0937049..f918987 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -72,9 +72,9 @@ func New(opts ...Option) *App { 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. +// Run initializes the configuration when required and dispatches +// the parsed CLI command to the appropriate handler. +// It returns 0 on success and 1 on error. func (a *App) Run(cli *cmd.CLI) int { needsConfig := !cli.GenConfig && !cli.ConfigPath && !cli.ShowVersion && !cli.ShowHelp @@ -85,6 +85,13 @@ func (a *App) Run(cli *cmd.CLI) int { } } + return a.dispatch(cli) +} + +// dispatch routes the parsed CLI command to the corresponding handler +// based on the provided flags. +// It returns 0 on success and 1 on error. +func (a *App) dispatch(cli *cmd.CLI) int { switch { case cli.Update: return a.updateCache(cli) From 55554b4cb323901740c18017182877028716daf1 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 12:04:06 +0530 Subject: [PATCH 07/58] fix(app): Fixed resource leaks --- internal/app/page.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/app/page.go b/internal/app/page.go index 232e7a5..9426493 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -185,6 +185,9 @@ func loadPage(path string) (*render.Page, error) { if err != nil { return nil, err } + defer func() { + _ = root.Close() + }() data, err := root.ReadFile(filepath.Base(path)) if err != nil { From da3a6a517f9e0e45930a10b85629c3954a592a1c Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 12:14:05 +0530 Subject: [PATCH 08/58] feat(app): Include contribution links --- internal/app/page.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/app/page.go b/internal/app/page.go index 9426493..f85dc65 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -14,6 +14,7 @@ import ( "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/pathutil" + "github.com/TheRootDaemon/tlgc/termcolor" ) // lookupAndRenderPage finds a page by name and renders it to the terminal. @@ -127,7 +128,21 @@ func (a *App) selectPage( return page, pathutil.PagePlatform(page), nil default: - return "", "", fmt.Errorf("page not found, try running tldr --update") + const ( + pageNotFound = `page not found, try running tldr --update + +If the page does not exist, you can create an issue here: +%s +or document it yourself and create a pull request here: +%s` + tldrIssues = "https://github.com/tldr-pages/tldr/issues" + tldrPulls = "https://github.com/tldr-pages/tldr/pulls" + ) + return "", "", fmt.Errorf( + pageNotFound, + termcolor.Sprint("bold", tldrIssues), + termcolor.Sprint("bold", tldrPulls), + ) } } From 692e1417ada7db83fb3e3587688735063a910f87 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 20 Jul 2026 21:54:04 +0530 Subject: [PATCH 09/58] cli: Add completions --- completions/_tlgc | 50 +++++++++++++++++++++++++++++++++++++++++++ completions/tlgc.bash | 32 +++++++++++++++++++++++++++ completions/tlgc.fish | 34 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 completions/_tlgc create mode 100644 completions/tlgc.bash create mode 100644 completions/tlgc.fish diff --git a/completions/_tlgc b/completions/_tlgc new file mode 100644 index 0000000..4b72000 --- /dev/null +++ b/completions/_tlgc @@ -0,0 +1,50 @@ +#compdef tlgc + +_pages() { + local -a pages=(${(uonzf)"$(tlgc --offline --list-all 2> /dev/null)"//:/\\:}) + _describe "PAGE" pages +} + +_languages() { + local -a languages=(${(uonzf)"$(tlgc --offline --list-languages 2> /dev/null)"//:/\\:}) + _describe "LANGUAGE_CODE" languages +} + +_platforms() { + local -a platforms=(${(uonzf)"$(tlgc --offline --list-platforms 2> /dev/null)"//:/\\:}) + _describe "PLATFORM" platforms +} + +_tlgc() { + _arguments -s -S \ + {-u,--update}"[Update the cache]" \ + {-l,--list}"[List all pages in the current platform]" \ + {-a,--list-all}"[List all pages]" \ + {-s,--search}"[Search for pages containing a keyword]" \ + --list-platforms"[List available platforms]" \ + --list-languages"[List installed languages]" \ + {-i,--info}"[Show cache information]" \ + {-r,--render}"[Render the specified tldr page]:FILE:_files" \ + --clean-cache"[Interactively delete contents of the cache directory]" \ + --gen-config"[Print the default config]" \ + --config-path"[Print the default config path]" \ + {-p,--platform}"[Specify the platform to use (linux, osx, windows, etc.)]:PLATFORM:_platforms" \ + {-L,--language}"[Specify the languages to use]:LANGUAGE_CODE:_languages" \ + --short-options"[Display short options wherever possible (e.g. '-s')]" \ + --long-options"[Display long options wherever possible (e.g. '--long')]" \ + --edit"[Display a link to edit the shown page on GitHub]" \ + {-o,--offline}"[Do not update the cache, even if it is stale]" \ + {-c,--compact}"[Strip empty lines from output]" \ + --no-compact"[Do not strip empty lines from output (overrides --compact)]" \ + {-R,--raw}"[Print pages in raw markdown instead of rendering them]" \ + --no-raw"[Render pages instead of printing raw file contents (overrides --raw)]" \ + {-q,--quiet}"[Suppress status messages and warnings]" \ + --verbose"[Be more verbose (can be specified twice)]" \ + --color"[Specify when to enable color [default: auto] [possible values: auto, always, never]]:WHEN:(auto always never)" \ + --config"[Specify an alternative path to the config file]:FILE:_files" \ + {-v,--version}"[Print version]" \ + {-h,--help}"[Print help]" \ + '*:PAGE:_pages' +} + +_tlgc diff --git a/completions/tlgc.bash b/completions/tlgc.bash new file mode 100644 index 0000000..2aa7896 --- /dev/null +++ b/completions/tlgc.bash @@ -0,0 +1,32 @@ +# shellcheck shell=bash + +_tlgc() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local prev="${COMP_WORDS[COMP_CWORD-1]}" + + local opts="-u -l -a -s -i -r -p -L -o -c -R -q -v -h \ + --update --list --list-all --search --list-platforms --list-languages \ + --info --render --clean-cache --gen-config --config-path --platform \ + --language --short-options --long-options --edit --offline --compact \ + --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" + + if [[ $cur == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "$opts" -- "$cur") + return 0 + fi + + case $prev in + -r|--render|--config) + mapfile -t COMPREPLY < <(compgen -f -- "$cur");; + --color) + mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur");; + -p|--platform) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-platforms 2> /dev/null)" -- "$cur");; + -L|--language) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-languages 2> /dev/null)" -- "$cur");; + *) + mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-all 2> /dev/null)" -- "$cur");; + esac +} + +complete -o bashdefault -F _tlgc tlgc diff --git a/completions/tlgc.fish b/completions/tlgc.fish new file mode 100644 index 0000000..1c9cf48 --- /dev/null +++ b/completions/tlgc.fish @@ -0,0 +1,34 @@ +complete -c tlgc -s u -l update -d "Update the cache" +complete -c tlgc -s l -l list -d "List all pages in the current platform" +complete -c tlgc -s a -l list-all -d "List all pages" +complete -c tlgc -s s -l search -d "Search for pages containing a keyword" +complete -c tlgc -l list-platforms -d "List available platforms" +complete -c tlgc -l list-languages -d "List installed languages" +complete -c tlgc -s i -l info -d "Show cache information" +complete -c tlgc -s r -l render -d "Render the specified tldr page" -r +complete -c tlgc -l clean-cache -d "Interactively delete contents of the cache directory" +complete -c tlgc -l gen-config -d "Print the default config" +complete -c tlgc -l config-path -d "Print the default config path" +complete -c tlgc -s p -l platform -d "Specify the platform to use (linux, osx, windows, etc.)" -x -a \ + "(tlgc --offline --list-platforms 2> /dev/null)" +complete -c tlgc -s L -l language -d "Specify the languages to use" -x -a \ + "(tlgc --offline --list-languages 2> /dev/null)" +complete -c tlgc -l short-options -d "Display short options wherever possible (e.g. '-s')" +complete -c tlgc -l long-options -d "Display long options wherever possible (e.g. '--long')" +complete -c tlgc -l edit -d "Display a link to edit the shown page on GitHub" +complete -c tlgc -s o -l offline -d "Do not update the cache, even if it is stale" +complete -c tlgc -s c -l compact -d "Strip empty lines from output" +complete -c tlgc -l no-compact -d "Do not strip empty lines from output (overrides --compact)" +complete -c tlgc -s R -l raw -d "Print pages in raw markdown instead of rendering them" +complete -c tlgc -l no-raw -d "Render pages instead of printing raw file contents (overrides --raw)" +complete -c tlgc -s q -l quiet -d "Suppress status messages and warnings" +complete -c tlgc -l verbose -d "Be more verbose (can be specified twice)" +complete -c tlgc -l color -d "Specify when to enable color [default: auto] [possible values: auto, always, never]" -x -a " + auto\t'Display color if standard output is a terminal and NO_COLOR is not set' + always\t'Always display color' + never\t'Never display color' +" +complete -c tlgc -l config -d "Specify an alternative path to the config file" -r +complete -c tlgc -s v -l version -d "Print version" +complete -c tlgc -s h -l help -d "Print help" +complete -c tlgc -f -a "(tlgc --offline --list-all 2> /dev/null)" From 8a04198585f6ff6be6c63684e7a4df13ef968131 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 25 Jul 2026 23:28:42 +0530 Subject: [PATCH 10/58] cmd: Error handling, similar flags --- cmd/diagnostic.go | 114 ++++++++++++++++++++++++ cmd/diagnostic_test.go | 174 ++++++++++++++++++++++++++++++++++++ cmd/match_flag.go | 83 ++++++++++++++++++ cmd/match_flag_test.go | 176 +++++++++++++++++++++++++++++++++++++ cmd/parse.go | 86 +----------------- cmd/parse_test.go | 153 +++++++++----------------------- cmd/validate.go | 114 ++++++++++++++++++++++++ cmd/validate_test.go | 195 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 899 insertions(+), 196 deletions(-) create mode 100644 cmd/diagnostic.go create mode 100644 cmd/diagnostic_test.go create mode 100644 cmd/match_flag.go create mode 100644 cmd/match_flag_test.go create mode 100644 cmd/validate.go create mode 100644 cmd/validate_test.go diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go new file mode 100644 index 0000000..0a3048d --- /dev/null +++ b/cmd/diagnostic.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "flag" + "fmt" + "strings" + + "github.com/TheRootDaemon/tlgc/termcolor" +) + +// fmtFlagError wraps flag parse errors with clap-style formatting. +func fmtFlagError(fs *flag.FlagSet, err error) error { + s := err.Error() + + switch { + case strings.HasPrefix(s, "flag provided but not defined: "): + raw := strings.TrimPrefix(s, "flag provided but not defined: ") + name := strings.TrimLeft(raw, "-") + var tip string + if sim := similarFlag(fs, name); sim != "" { + tip = fmt.Sprintf( + "\n\n %s a similar argument exists: %s", + termcolor.Sprint("bold green", "tip:"), + termcolor.Sprint("bold blue", flagDisplay(sim)), + ) + } + return fmtUsage( + "unexpected argument %s found%s", + termcolor.Sprint("bold cyan", flagDisplay(name)), + tip, + ) + case strings.HasPrefix(s, "flag needs an argument: "): + raw := strings.TrimPrefix(s, "flag needs an argument: ") + name := strings.TrimLeft(raw, "-") + return fmtUsage("flag %s requires an argument", termcolor.Sprint("bold blue", flagDisplay(name))) + default: + return fmtUsage("%s", s) + } +} + +// fmtConflictError builds a clap-style error for conflicting operations. +func fmtConflictError(cli *CLI) error { + ops := activeOps(cli) + if len(ops) < 2 { + return fmt.Errorf("only one operation can be specified at a time") + } + return fmtUsage( + "argument %s cannot be used with %s", + termcolor.Sprint("blue", ops[0]), + termcolor.Sprint("blue", ops[1]), + ) +} + +// fmtUsage wraps a formatted message with the standard usage footer. +func fmtUsage(format string, args ...any) error { + usage := fmt.Sprintf( + "\n\n%s %s [OPTIONS] [PAGE]...\n\nFor more information, try %s.", + termcolor.Sprint("bold underline", "Usage:"), + termcolor.Sprint("bold", "tlgc"), + termcolor.Sprint("bold", "'--help'"), + ) + return fmt.Errorf(format+usage, args...) +} + +// flagDisplay returns the display form of a flag name, +// "-x" for short, "--xxx" for long. +func flagDisplay(name string) string { + if len(name) == 1 { + return "-" + name + } + return "--" + name +} + +// activeOps returns display names for all active operations in cli. +func activeOps(cli *CLI) []string { + var ops []string + if len(cli.Page) > 0 { + ops = append(ops, "[PAGE]...") + } + if cli.Update { + ops = append(ops, "--update") + } + if cli.List { + ops = append(ops, "--list") + } + if cli.ListAll { + ops = append(ops, "--list-all") + } + if cli.Search != "" { + ops = append(ops, "--search ") + } + if cli.ListPlatforms { + ops = append(ops, "--list-platforms") + } + if cli.ListLanguages { + ops = append(ops, "--list-languages") + } + if cli.Info { + ops = append(ops, "--info") + } + if cli.Render != "" { + ops = append(ops, "--render ") + } + if cli.CleanCache { + ops = append(ops, "--clean-cache") + } + if cli.GenConfig { + ops = append(ops, "--gen-config") + } + if cli.ConfigPath { + ops = append(ops, "--config-path") + } + return ops +} diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go new file mode 100644 index 0000000..0a88bbd --- /dev/null +++ b/cmd/diagnostic_test.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "flag" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFlagDisplay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + arg string + want string + }{ + {name: "short", arg: "x", want: "-x"}, + {name: "long", arg: "update", want: "--update"}, + {name: "single_char_short", arg: "u", want: "-u"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := flagDisplay(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestActiveOps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want []string + }{ + { + name: "empty", + cli: CLI{}, + want: nil, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: []string{"[PAGE]..."}, + }, + { + name: "update", + cli: CLI{Update: true}, + want: []string{"--update"}, + }, + { + name: "search", + cli: CLI{Search: "ngi"}, + want: []string{"--search "}, + }, + { + name: "render", + cli: CLI{Render: "file.md"}, + want: []string{"--render "}, + }, + { + name: "multiple", + cli: CLI{Update: true, Search: "foo"}, + want: []string{"--update", "--search "}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := activeOps(&tt.cli) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFmtUsage(t *testing.T) { + t.Parallel() + + err := fmtUsage("test message %d", 42) + assert.Error(t, err) + assert.Contains(t, err.Error(), "test message 42") + assert.Contains(t, err.Error(), "Usage:") + assert.Contains(t, err.Error(), "tlgc") + assert.Contains(t, err.Error(), "--help") +} + +func TestFmtFlagErrorUndefined(t *testing.T) { + t.Parallel() + + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.Bool("update", false, "") + fs.Bool("search", false, "") + + err := fs.Parse([]string{"--bogus"}) + assert.Error(t, err) + + err = fmtFlagError(fs, err) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected argument") + assert.Contains(t, err.Error(), "--bogus") +} + +func TestFmtFlagErrorUndefinedTip(t *testing.T) { + t.Parallel() + + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.Bool("update", false, "") + fs.Bool("search", false, "") + + err := fs.Parse([]string{"--searc"}) + assert.Error(t, err) + + err = fmtFlagError(fs, err) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected argument") + assert.Contains(t, err.Error(), "--searc") + assert.Contains(t, err.Error(), "similar argument") + assert.Contains(t, err.Error(), "--search") +} + +func TestFmtFlagErrorNeedsArg(t *testing.T) { + t.Parallel() + + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("search", "", "") + + err := fs.Parse([]string{"--search"}) + assert.Error(t, err) + + err = fmtFlagError(fs, err) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires an argument") +} + +func TestFmtConflictError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + contains []string + }{ + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + contains: []string{"cannot be used with", "--update", "--list"}, + }, + { + name: "page_and_search", + cli: CLI{Page: []string{"tar"}, Search: "foo"}, + contains: []string{"cannot be used with", "[PAGE]...", "--search"}, + }, + { + name: "one_operation_fallback", + cli: CLI{Update: true}, + contains: []string{"only one operation"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := fmtConflictError(&tt.cli) + assert.Error(t, err) + for _, s := range tt.contains { + assert.True(t, strings.Contains(err.Error(), s), + "error %q should contain %q", err.Error(), s) + } + }) + } +} diff --git a/cmd/match_flag.go b/cmd/match_flag.go new file mode 100644 index 0000000..52fe453 --- /dev/null +++ b/cmd/match_flag.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "flag" + "strings" +) + +// similarFlag finds the closest defined long flag to name, within threshold. +func similarFlag(fs *flag.FlagSet, name string) string { + if prefix := prefixFlag(fs, name); prefix != "" { + return prefix + } + return fuzzyFlag(fs, name) +} + +// prefixFlag returns the shortest defined long flag that starts with name. +func prefixFlag(fs *flag.FlagSet, name string) string { + var best string + + fs.VisitAll(func(f *flag.Flag) { + if len(f.Name) <= 1 { + return + } + + if strings.HasPrefix(f.Name, name) && + (best == "" || len(f.Name) < len(best)) { + best = f.Name + } + }) + + return best +} + +// fuzzyFlag returns the closest defined long flag to name by Levenshtein distance. +func fuzzyFlag(fs *flag.FlagSet, name string) string { + var best string + bestDist := 3 + + fs.VisitAll(func(f *flag.Flag) { + if len(f.Name) <= 1 { + return + } + + d := editDistance(name, f.Name) + if d < bestDist { + bestDist = d + best = f.Name + } + }) + + return best +} + +// editDistance returns the Levenshtein distance between a and b. +func editDistance(a, b string) int { + rows, cols := len(a)+1, len(b)+1 + distances := make([][]int, rows) + + for i := range distances { + distances[i] = make([]int, cols) + distances[i][0] = i + } + + for j := range cols { + distances[0][j] = j + } + + for i := 1; i < rows; i++ { + for j := 1; j < cols; j++ { + cost := 0 + if a[i-1] != b[j-1] { + cost = 1 + } + distances[i][j] = min( + distances[i-1][j]+1, + distances[i][j-1]+1, + distances[i-1][j-1]+cost, + ) + } + } + + return distances[rows-1][cols-1] +} diff --git a/cmd/match_flag_test.go b/cmd/match_flag_test.go new file mode 100644 index 0000000..ad27ad7 --- /dev/null +++ b/cmd/match_flag_test.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEditDistance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b string + want int + }{ + {name: "identical", a: "abc", b: "abc", want: 0}, + {name: "empty_both", a: "", b: "", want: 0}, + {name: "empty_a", a: "", b: "abc", want: 3}, + {name: "empty_b", a: "abc", b: "", want: 3}, + {name: "substitution", a: "abc", b: "axc", want: 1}, + {name: "insertion", a: "ac", b: "abc", want: 1}, + {name: "deletion", a: "abc", b: "ac", want: 1}, + {name: "full_mismatch", a: "abc", b: "xyz", want: 3}, + {name: "typo", a: "searc", b: "search", want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := editDistance(tt.a, tt.b) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPrefixFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "exact_prefix", + flagSet: []string{"update", "list", "list-all"}, + query: "lis", + want: "list", + }, + { + name: "shortest_prefix_wins", + flagSet: []string{"list", "list-all", "list-platforms"}, + query: "list", + want: "list", + }, + { + name: "no_match", + flagSet: []string{"update", "list"}, + query: "bogus", + want: "", + }, + { + name: "skips_short_flags", + flagSet: []string{"u", "q"}, + query: "u", + want: "", + }, + { + name: "single_match", + flagSet: []string{"search"}, + query: "sea", + want: "search", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := prefixFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFuzzyFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "distance_1", + flagSet: []string{"search", "update"}, + query: "searh", + want: "search", + }, + { + name: "distance_2", + flagSet: []string{"verbose", "offline"}, + query: "verbos", + want: "verbose", + }, + { + name: "no_match_too_far", + flagSet: []string{"update", "list"}, + query: "xyz", + want: "", + }, + { + name: "skips_short_flags", + flagSet: []string{"u", "update"}, + query: "updaet", + want: "update", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := fuzzyFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSimilarFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagSet []string + query string + want string + }{ + { + name: "prefix_priority", + flagSet: []string{"search", "update"}, + query: "sea", + want: "search", + }, + { + name: "fuzzy_fallback", + flagSet: []string{"search", "update"}, + query: "searh", + want: "search", + }, + { + name: "no_match", + flagSet: []string{"update", "list"}, + query: "xyz", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := similarFlag(fs, tt.query) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/parse.go b/cmd/parse.go index d2d8b4a..36f5b04 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -2,11 +2,8 @@ package cmd import ( "flag" - "fmt" "os" "strings" - - "github.com/TheRootDaemon/tlgc/version" ) // Parse parses the process command-line arguments into a CLI value. @@ -221,88 +218,7 @@ func parse(args []string) (*CLI, error) { "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.String(), - ) - return cli, nil - } - - // show help - if cli.ShowHelp { - help() - return cli, nil - } - - // positional arguments - cli.Page = fs.Args() - - // validate that exactly one operation is active - ops := cli.operationCount() - if ops == 0 { - help() - 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 + return Validate(cli, fs, args) } // reorderFlags moves all flags before positional arguments diff --git a/cmd/parse_test.go b/cmd/parse_test.go index b70b2d4..3b8151c 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -11,10 +11,11 @@ func TestParse(t *testing.T) { t.Parallel() tests := []struct { - name string - args []string - check func(t *testing.T, cli *CLI) - wantErr bool + name string + args []string + check func(t *testing.T, cli *CLI) + wantErr bool + errCheck func(t *testing.T, err error) }{ // operations (short forms) { @@ -402,26 +403,58 @@ func TestParse(t *testing.T) { name: "two_operations", args: []string{"-u", "-l"}, wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + assert.ErrorContains(t, err, "--update") + assert.ErrorContains(t, err, "--list") + }, }, { name: "three_operations", args: []string{"-u", "-l", "-a"}, wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + assert.ErrorContains(t, err, "--update") + assert.ErrorContains(t, err, "--list") + }, }, { name: "invalid_color", args: []string{"--color", "invalid", "-u"}, wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "invalid value") + }, }, { name: "unknown_flag", args: []string{"--bogus"}, wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + assert.ErrorContains(t, err, "--bogus") + }, }, { name: "unknown_short_flag", args: []string{"-x"}, wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + assert.ErrorContains(t, err, "-x") + }, + }, + { + name: "unknown_flag_tip", + args: []string{"--searc"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "unexpected argument") + assert.ErrorContains(t, err, "--searc") + assert.ErrorContains(t, err, "similar argument") + assert.ErrorContains(t, err, "--search") + }, }, } @@ -429,7 +462,11 @@ func TestParse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cli, err := parse(tt.args) if tt.wantErr { - assert.Error(t, err) + if tt.errCheck != nil { + tt.errCheck(t, err) + } else { + assert.Error(t, err) + } return } require.NoError(t, err) @@ -441,112 +478,6 @@ func TestParse(t *testing.T) { } } -func TestOperationCount(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cli CLI - want int - }{ - { - name: "none", - cli: CLI{}, - want: 0, - }, - { - name: "page", - cli: CLI{Page: []string{"tar"}}, - want: 1, - }, - { - name: "update", - cli: CLI{Update: true}, - want: 1, - }, - { - name: "list", - cli: CLI{List: true}, - want: 1, - }, - { - name: "list_all", - cli: CLI{ListAll: true}, - want: 1, - }, - { - name: "search", - cli: CLI{Search: "ngi"}, - want: 1, - }, - { - name: "list_platforms", - cli: CLI{ListPlatforms: true}, - want: 1, - }, - { - name: "list_languages", - cli: CLI{ListLanguages: true}, - want: 1, - }, - { - name: "info", - cli: CLI{Info: true}, - want: 1, - }, - { - name: "render", - cli: CLI{Render: "file.md"}, - want: 1, - }, - { - name: "clean_cache", - cli: CLI{CleanCache: true}, - want: 1, - }, - { - name: "gen_config", - cli: CLI{GenConfig: true}, - want: 1, - }, - { - name: "config_path", - cli: CLI{ConfigPath: true}, - want: 1, - }, - { - name: "two_operations", - cli: CLI{Update: true, List: true}, - want: 2, - }, - { - name: "all_operations", - cli: CLI{ - Page: []string{"tar"}, - Update: true, - List: true, - ListAll: true, - Search: "ngi", - ListPlatforms: true, - ListLanguages: true, - Info: true, - Render: "file.md", - CleanCache: true, - GenConfig: true, - ConfigPath: true, - }, - want: 12, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.cli.operationCount() - assert.Equal(t, tt.want, got) - }) - } -} - func TestReorderFlags(t *testing.T) { t.Parallel() diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..b1b0735 --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "flag" + "fmt" + "io" + + "github.com/TheRootDaemon/tlgc/termcolor" + "github.com/TheRootDaemon/tlgc/version" +) + +// Validate parses the flag set, validates the CLI, and returns the result. +func Validate(cli *CLI, fs *flag.FlagSet, args []string) (*CLI, error) { + fs.Usage = func() {} + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return nil, fmtFlagError(fs, err) + } + + cli.Page = fs.Args() + + if err := validate(cli); err != nil { + return nil, err + } + + return cli, nil +} + +// validate checks that the parsed CLI has valid flags. +func validate(cli *CLI) error { + switch cli.Color { + case "auto", "always", "never": + default: + return fmtUsage( + "invalid value for %s (expected %s, %s, %s)", + termcolor.Sprint("bold blue", "--color"), + termcolor.Sprint("blue", "auto"), + termcolor.Sprint("blue", "always"), + termcolor.Sprint("blue", "never"), + ) + } + + // show version + if cli.ShowVersion { + fmt.Printf( + "tlgc %s (implementing client specification v2.3)\n", + version.String(), + ) + return nil + } + + // show help + if cli.ShowHelp { + help() + return nil + } + + // validate that exactly one operation is active + ops := cli.operationCount() + if ops == 0 { + help() + return nil + } + if ops > 1 { + return fmtConflictError(cli) + } + + return 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/cmd/validate_test.go b/cmd/validate_test.go new file mode 100644 index 0000000..2beae13 --- /dev/null +++ b/cmd/validate_test.go @@ -0,0 +1,195 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + errContains string + }{ + { + name: "single_operation_update", + cli: CLI{Color: "auto", Update: true}, + }, + { + name: "single_operation_list", + cli: CLI{Color: "auto", List: true}, + }, + { + name: "single_operation_search", + cli: CLI{Color: "auto", Search: "ngi"}, + }, + { + name: "no_operations", + cli: CLI{Color: "auto"}, + wantErr: false, + }, + { + name: "invalid_color", + cli: CLI{Color: "invalid", Update: true}, + wantErr: true, + errContains: "invalid value", + }, + { + name: "two_operations", + cli: CLI{Color: "auto", Update: true, List: true}, + wantErr: true, + errContains: "cannot be used with", + }, + { + name: "three_operations", + cli: CLI{Color: "auto", Update: true, List: true, ListAll: true}, + wantErr: true, + errContains: "cannot be used with", + }, + { + name: "page_and_update", + cli: CLI{Color: "auto", Page: []string{"tar"}, Update: true}, + wantErr: true, + errContains: "cannot be used with", + }, + { + name: "valid_color_auto", + cli: CLI{Color: "auto", Update: true}, + wantErr: false, + }, + { + name: "valid_color_always", + cli: CLI{Color: "always", Update: true}, + wantErr: false, + }, + { + name: "valid_color_never", + cli: CLI{Color: "never", Update: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validate(&tt.cli) + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestOperationCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want int + }{ + { + name: "none", + cli: CLI{}, + want: 0, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: 1, + }, + { + name: "update", + cli: CLI{Update: true}, + want: 1, + }, + { + name: "list", + cli: CLI{List: true}, + want: 1, + }, + { + name: "list_all", + cli: CLI{ListAll: true}, + want: 1, + }, + { + name: "search", + cli: CLI{Search: "ngi"}, + want: 1, + }, + { + name: "list_platforms", + cli: CLI{ListPlatforms: true}, + want: 1, + }, + { + name: "list_languages", + cli: CLI{ListLanguages: true}, + want: 1, + }, + { + name: "info", + cli: CLI{Info: true}, + want: 1, + }, + { + name: "render", + cli: CLI{Render: "file.md"}, + want: 1, + }, + { + name: "clean_cache", + cli: CLI{CleanCache: true}, + want: 1, + }, + { + name: "gen_config", + cli: CLI{GenConfig: true}, + want: 1, + }, + { + name: "config_path", + cli: CLI{ConfigPath: true}, + want: 1, + }, + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + want: 2, + }, + { + name: "all_operations", + cli: CLI{ + Page: []string{"tar"}, + Update: true, + List: true, + ListAll: true, + Search: "ngi", + ListPlatforms: true, + ListLanguages: true, + Info: true, + Render: "file.md", + CleanCache: true, + GenConfig: true, + ConfigPath: true, + }, + want: 12, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cli.operationCount() + assert.Equal(t, tt.want, got) + }) + } +} From fb70c62ffcf6ad6f1fb08be73fbef23fd85be792 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 26 Jul 2026 12:25:37 +0530 Subject: [PATCH 11/58] tests: Fix flaky tests --- cmd/diagnostic_test.go | 205 ++++++++++++++++++++++------------------- cmd/match_flag_test.go | 84 ++++++++--------- cmd/parse_test.go | 9 -- cmd/validate_test.go | 38 +++----- 4 files changed, 166 insertions(+), 170 deletions(-) diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index 0a88bbd..a7b8df5 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -8,6 +8,115 @@ import ( "github.com/stretchr/testify/assert" ) +func TestFmtFlagError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*flag.FlagSet) + args []string + contains []string + }{ + { + name: "undefined", + setup: func(fs *flag.FlagSet) { + fs.Bool("update", false, "") + fs.Bool("search", false, "") + }, + args: []string{"--bogus"}, + contains: []string{ + "unexpected argument", + }, + }, + { + name: "undefined_with_tip", + setup: func(fs *flag.FlagSet) { + fs.Bool("update", false, "") + fs.Bool("search", false, "") + }, + args: []string{"--searc"}, + contains: []string{ + "unexpected argument", + }, + }, + { + name: "missing_argument", + setup: func(fs *flag.FlagSet) { + fs.String("search", "", "") + }, + args: []string{"--search"}, + contains: []string{ + "requires an argument", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + tt.setup(fs) + + err := fs.Parse(tt.args) + assert.Error(t, err) + + err = fmtFlagError(fs, err) + assert.Error(t, err) + + for _, s := range tt.contains { + assert.True(t, strings.Contains(err.Error(), s)) + } + }) + } +} + +func TestFmtConflictError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + contains []string + }{ + { + name: "two_operations", + cli: CLI{Update: true, List: true}, + contains: []string{"cannot be used with", "--update", "--list"}, + }, + { + name: "page_and_search", + cli: CLI{Page: []string{"tar"}, Search: "foo"}, + contains: []string{"cannot be used with", "[PAGE]...", "--search"}, + }, + { + name: "one_operation_fallback", + cli: CLI{Update: true}, + contains: []string{"only one operation"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := fmtConflictError(&tt.cli) + assert.Error(t, err) + for _, s := range tt.contains { + assert.True(t, strings.Contains(err.Error(), s), + "error %q should contain %q", err.Error(), s) + } + }) + } +} + +func TestFmtUsage(t *testing.T) { + t.Parallel() + + err := fmtUsage("test message %d", 42) + assert.Error(t, err) + assert.Contains(t, err.Error(), "test message 42") + assert.Contains(t, err.Error(), "Usage:") + assert.Contains(t, err.Error(), "tlgc") + assert.Contains(t, err.Error(), "--help") +} + func TestFlagDisplay(t *testing.T) { t.Parallel() @@ -76,99 +185,3 @@ func TestActiveOps(t *testing.T) { }) } } - -func TestFmtUsage(t *testing.T) { - t.Parallel() - - err := fmtUsage("test message %d", 42) - assert.Error(t, err) - assert.Contains(t, err.Error(), "test message 42") - assert.Contains(t, err.Error(), "Usage:") - assert.Contains(t, err.Error(), "tlgc") - assert.Contains(t, err.Error(), "--help") -} - -func TestFmtFlagErrorUndefined(t *testing.T) { - t.Parallel() - - fs := flag.NewFlagSet("test", flag.ContinueOnError) - fs.Bool("update", false, "") - fs.Bool("search", false, "") - - err := fs.Parse([]string{"--bogus"}) - assert.Error(t, err) - - err = fmtFlagError(fs, err) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unexpected argument") - assert.Contains(t, err.Error(), "--bogus") -} - -func TestFmtFlagErrorUndefinedTip(t *testing.T) { - t.Parallel() - - fs := flag.NewFlagSet("test", flag.ContinueOnError) - fs.Bool("update", false, "") - fs.Bool("search", false, "") - - err := fs.Parse([]string{"--searc"}) - assert.Error(t, err) - - err = fmtFlagError(fs, err) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unexpected argument") - assert.Contains(t, err.Error(), "--searc") - assert.Contains(t, err.Error(), "similar argument") - assert.Contains(t, err.Error(), "--search") -} - -func TestFmtFlagErrorNeedsArg(t *testing.T) { - t.Parallel() - - fs := flag.NewFlagSet("test", flag.ContinueOnError) - fs.String("search", "", "") - - err := fs.Parse([]string{"--search"}) - assert.Error(t, err) - - err = fmtFlagError(fs, err) - assert.Error(t, err) - assert.Contains(t, err.Error(), "requires an argument") -} - -func TestFmtConflictError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cli CLI - contains []string - }{ - { - name: "two_operations", - cli: CLI{Update: true, List: true}, - contains: []string{"cannot be used with", "--update", "--list"}, - }, - { - name: "page_and_search", - cli: CLI{Page: []string{"tar"}, Search: "foo"}, - contains: []string{"cannot be used with", "[PAGE]...", "--search"}, - }, - { - name: "one_operation_fallback", - cli: CLI{Update: true}, - contains: []string{"only one operation"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := fmtConflictError(&tt.cli) - assert.Error(t, err) - for _, s := range tt.contains { - assert.True(t, strings.Contains(err.Error(), s), - "error %q should contain %q", err.Error(), s) - } - }) - } -} diff --git a/cmd/match_flag_test.go b/cmd/match_flag_test.go index ad27ad7..235eb3e 100644 --- a/cmd/match_flag_test.go +++ b/cmd/match_flag_test.go @@ -7,28 +7,42 @@ import ( "github.com/stretchr/testify/assert" ) -func TestEditDistance(t *testing.T) { +func TestSimilarFlag(t *testing.T) { t.Parallel() tests := []struct { - name string - a, b string - want int + name string + flagSet []string + query string + want string }{ - {name: "identical", a: "abc", b: "abc", want: 0}, - {name: "empty_both", a: "", b: "", want: 0}, - {name: "empty_a", a: "", b: "abc", want: 3}, - {name: "empty_b", a: "abc", b: "", want: 3}, - {name: "substitution", a: "abc", b: "axc", want: 1}, - {name: "insertion", a: "ac", b: "abc", want: 1}, - {name: "deletion", a: "abc", b: "ac", want: 1}, - {name: "full_mismatch", a: "abc", b: "xyz", want: 3}, - {name: "typo", a: "searc", b: "search", want: 1}, + { + name: "prefix_priority", + flagSet: []string{"search", "update"}, + query: "sea", + want: "search", + }, + { + name: "fuzzy_fallback", + flagSet: []string{"search", "update"}, + query: "searh", + want: "search", + }, + { + name: "no_match", + flagSet: []string{"update", "list"}, + query: "xyz", + want: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := editDistance(tt.a, tt.b) + fs := flag.NewFlagSet("test", flag.ContinueOnError) + for _, name := range tt.flagSet { + fs.Bool(name, false, "") + } + got := similarFlag(fs, tt.query) assert.Equal(t, tt.want, got) }) } @@ -134,42 +148,28 @@ func TestFuzzyFlag(t *testing.T) { } } -func TestSimilarFlag(t *testing.T) { +func TestEditDistance(t *testing.T) { t.Parallel() tests := []struct { - name string - flagSet []string - query string - want string + name string + a, b string + want int }{ - { - name: "prefix_priority", - flagSet: []string{"search", "update"}, - query: "sea", - want: "search", - }, - { - name: "fuzzy_fallback", - flagSet: []string{"search", "update"}, - query: "searh", - want: "search", - }, - { - name: "no_match", - flagSet: []string{"update", "list"}, - query: "xyz", - want: "", - }, + {name: "identical", a: "abc", b: "abc", want: 0}, + {name: "empty_both", a: "", b: "", want: 0}, + {name: "empty_a", a: "", b: "abc", want: 3}, + {name: "empty_b", a: "abc", b: "", want: 3}, + {name: "substitution", a: "abc", b: "axc", want: 1}, + {name: "insertion", a: "ac", b: "abc", want: 1}, + {name: "deletion", a: "abc", b: "ac", want: 1}, + {name: "full_mismatch", a: "abc", b: "xyz", want: 3}, + {name: "typo", a: "searc", b: "search", want: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fs := flag.NewFlagSet("test", flag.ContinueOnError) - for _, name := range tt.flagSet { - fs.Bool(name, false, "") - } - got := similarFlag(fs, tt.query) + got := editDistance(tt.a, tt.b) assert.Equal(t, tt.want, got) }) } diff --git a/cmd/parse_test.go b/cmd/parse_test.go index 3b8151c..67d673d 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -405,8 +405,6 @@ func TestParse(t *testing.T) { wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "cannot be used with") - assert.ErrorContains(t, err, "--update") - assert.ErrorContains(t, err, "--list") }, }, { @@ -415,8 +413,6 @@ func TestParse(t *testing.T) { wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "cannot be used with") - assert.ErrorContains(t, err, "--update") - assert.ErrorContains(t, err, "--list") }, }, { @@ -433,7 +429,6 @@ func TestParse(t *testing.T) { wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "unexpected argument") - assert.ErrorContains(t, err, "--bogus") }, }, { @@ -442,7 +437,6 @@ func TestParse(t *testing.T) { wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "unexpected argument") - assert.ErrorContains(t, err, "-x") }, }, { @@ -451,9 +445,6 @@ func TestParse(t *testing.T) { wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "unexpected argument") - assert.ErrorContains(t, err, "--searc") - assert.ErrorContains(t, err, "similar argument") - assert.ErrorContains(t, err, "--search") }, }, } diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 2beae13..7b9b00c 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -10,10 +10,9 @@ func TestValidate(t *testing.T) { t.Parallel() tests := []struct { - name string - cli CLI - wantErr bool - errContains string + name string + cli CLI + wantErr bool }{ { name: "single_operation_update", @@ -33,28 +32,24 @@ func TestValidate(t *testing.T) { wantErr: false, }, { - name: "invalid_color", - cli: CLI{Color: "invalid", Update: true}, - wantErr: true, - errContains: "invalid value", + name: "invalid_color", + cli: CLI{Color: "invalid", Update: true}, + wantErr: true, }, { - name: "two_operations", - cli: CLI{Color: "auto", Update: true, List: true}, - wantErr: true, - errContains: "cannot be used with", + name: "two_operations", + cli: CLI{Color: "auto", Update: true, List: true}, + wantErr: true, }, { - name: "three_operations", - cli: CLI{Color: "auto", Update: true, List: true, ListAll: true}, - wantErr: true, - errContains: "cannot be used with", + name: "three_operations", + cli: CLI{Color: "auto", Update: true, List: true, ListAll: true}, + wantErr: true, }, { - name: "page_and_update", - cli: CLI{Color: "auto", Page: []string{"tar"}, Update: true}, - wantErr: true, - errContains: "cannot be used with", + name: "page_and_update", + cli: CLI{Color: "auto", Page: []string{"tar"}, Update: true}, + wantErr: true, }, { name: "valid_color_auto", @@ -78,9 +73,6 @@ func TestValidate(t *testing.T) { err := validate(&tt.cli) if tt.wantErr { assert.Error(t, err) - if tt.errContains != "" { - assert.ErrorContains(t, err, tt.errContains) - } } else { assert.NoError(t, err) } From 7d8d3da784ccb254ff798af213557b92f46f1bad Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 11:24:29 +0530 Subject: [PATCH 12/58] cmd: Change help flag --- cmd/diagnostic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 0a3048d..07d5811 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -57,7 +57,7 @@ func fmtUsage(format string, args ...any) error { "\n\n%s %s [OPTIONS] [PAGE]...\n\nFor more information, try %s.", termcolor.Sprint("bold underline", "Usage:"), termcolor.Sprint("bold", "tlgc"), - termcolor.Sprint("bold", "'--help'"), + termcolor.Sprint("bold blue", "--help"), ) return fmt.Errorf(format+usage, args...) } From 264cdbc5c586924423fa2d530a57b6947b5c510a Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 15:31:55 +0530 Subject: [PATCH 13/58] feat(browse): Add cross-platform browser capabilities for --browse --- browser/browse.go | 17 +++++ browser/browse_test.go | 47 +++++++++++++ browser/open.go | 50 ++++++++++++++ browser/open_test.go | 152 +++++++++++++++++++++++++++++++++++++++++ browser/wsl.go | 29 ++++++++ browser/wsl_test.go | 70 +++++++++++++++++++ 6 files changed, 365 insertions(+) create mode 100644 browser/browse.go create mode 100644 browser/browse_test.go create mode 100644 browser/open.go create mode 100644 browser/open_test.go create mode 100644 browser/wsl.go create mode 100644 browser/wsl_test.go diff --git a/browser/browse.go b/browser/browse.go new file mode 100644 index 0000000..6a15940 --- /dev/null +++ b/browser/browse.go @@ -0,0 +1,17 @@ +package browser + +import "os/exec" + +// browse starts the named program with the given arguments +// and waits for it to complete. +var browse = func(name string, args ...string) error { + // #nosec G204 + // executable names are hardcoded by this package + // and never originate from user input. + cmd := exec.Command(name, args...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + return cmd.Run() +} diff --git a/browser/browse_test.go b/browser/browse_test.go new file mode 100644 index 0000000..61f9ab4 --- /dev/null +++ b/browser/browse_test.go @@ -0,0 +1,47 @@ +package browser + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + command string + args []string + wantErr bool + }{ + { + name: "valid_command", + command: "echo", + args: []string{"hello"}, + wantErr: false, + }, + { + name: "invalid_command", + command: "nonexistent-command-xyz", + args: nil, + wantErr: true, + }, + { + name: "command_with_empty_args", + command: "echo", + args: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := browse(tt.command, tt.args...) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/browser/open.go b/browser/open.go new file mode 100644 index 0000000..b640d81 --- /dev/null +++ b/browser/open.go @@ -0,0 +1,50 @@ +package browser + +import ( + "fmt" + "os" + "runtime" +) + +// Open opens the given URL in the system browser. +// It detects the platform and chooses the appropriate command. +// +// On macOS and Windows it checks for remote/SSH sessions first. +// On Linux (including WSL) it checks for an active display server. +// Returns an error if no display server is available. +func Open(url string) error { + switch runtime.GOOS { + case "darwin": + return browse("open", url) + case "windows": + return browse("explorer.exe", url) + case "linux": + return openOnLinux(url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +// openOnLinux handles browser opening on Linux and WSL. +func openOnLinux(url string) error { + if runningOnWSL() { + return openOnWSL(url) + } + + if !hasDisplay() { + return fmt.Errorf("no display server detected") + } + + return browse("xdg-open", url) +} + +// openOnWSL handles browser opening inside Windows Subsystem for Linux. +func openOnWSL(url string) error { + return browse("explorer.exe", url) +} + +// hasDisplay checks whether a display server is available +// by testing the DISPLAY and WAYLAND_DISPLAY environment variables. +func hasDisplay() bool { + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" +} diff --git a/browser/open_test.go b/browser/open_test.go new file mode 100644 index 0000000..8ddb9eb --- /dev/null +++ b/browser/open_test.go @@ -0,0 +1,152 @@ +package browser + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +type call struct { + name string + args []string +} + +func TestOpenOnLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("openOnLinux only runs on Linux") + } + if runningOnWSL() { + t.Skip("WSL routes to explorer.exe, tested separately") + } + + tests := []struct { + name string + display string + wayland string + wantErr bool + wantCalls []call + }{ + { + name: "with display", + display: ":0", + wantCalls: []call{ + {name: "xdg-open", args: []string{"https://example.com"}}, + }, + }, + { + name: "no display", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("DISPLAY", tt.display) + t.Setenv("WAYLAND_DISPLAY", tt.wayland) + + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := openOnLinux("https://example.com") + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), "no display server detected") + assert.Empty(t, calls, "browse should not be called on error") + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantCalls, calls) + }) + } +} + +func TestOpenOnWSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("openOnWSL only runs on Linux") + } + if !runningOnWSL() { + t.Skip("not running on WSL") + } + + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := openOnWSL("https://example.com") + assert.NoError(t, err) + assert.Equal(t, []call{ + {name: "explorer.exe", args: []string{"https://example.com"}}, + }, calls) +} + +func TestOpen(t *testing.T) { + t.Setenv("DISPLAY", "") + t.Setenv("WAYLAND_DISPLAY", "") + + switch runtime.GOOS { + case "linux": + if runningOnWSL() { + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := Open("https://example.com") + assert.NoError(t, err) + assert.Equal( + t, + []call{ + { + name: "explorer.exe", + args: []string{"https://example.com"}, + }, + }, calls, + ) + } else { + err := Open("https://example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no display server detected") + } + default: + err := Open("https://example.com") + assert.Error(t, err) + } +} + +func TestHasDisplay(t *testing.T) { + tests := []struct { + name string + display string + wayland string + want bool + }{ + {name: "neither set", display: "", wayland: "", want: false}, + {name: "x11 only", display: ":0", wayland: "", want: true}, + {name: "wayland only", display: "", wayland: "wayland-0", want: true}, + {name: "both set", display: ":0", wayland: "wayland-0", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("DISPLAY", tt.display) + t.Setenv("WAYLAND_DISPLAY", tt.wayland) + assert.Equal(t, tt.want, hasDisplay()) + }) + } +} + +// mockBrowse replaces the package-level browse function for the duration of t. +func mockBrowse(t *testing.T, fn func(string, ...string) error) { + t.Helper() + oldBrowse := browse + browse = fn + t.Cleanup(func() { browse = oldBrowse }) +} diff --git a/browser/wsl.go b/browser/wsl.go new file mode 100644 index 0000000..c0107f4 --- /dev/null +++ b/browser/wsl.go @@ -0,0 +1,29 @@ +package browser + +import ( + "os" + "strings" +) + +// runningOnWSL reports whether the current process is running +// inside Windows Subsystem for Linux (WSL). +func runningOnWSL() bool { + return isWSL(os.ReadFile) +} + +// isWSL reports whether the system is running inside Windows Subsystem for Linux (WSL) +// by inspecting the Linux kernel version information. +// +// The readFile function is injected to allow the detection logic to be +// tested without reading from the real filesystem. +func isWSL(readFile func(string) ([]byte, error)) bool { + data, err := readFile("/proc/version") + if err != nil { + return false + } + + return strings.Contains( + strings.ToLower(string(data)), + "microsoft", + ) +} diff --git a/browser/wsl_test.go b/browser/wsl_test.go new file mode 100644 index 0000000..6dd7468 --- /dev/null +++ b/browser/wsl_test.go @@ -0,0 +1,70 @@ +package browser + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsWSL(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + { + name: "wsl2_marker", + content: "Linux version 5.10.16.3-microsoft-standard-WSL2", + want: true, + }, + { + name: "microsoft_mixed_case", + content: "Linux version 5.15.0 (Microsoft@Microsoft.com) (gcc 11.2.0) #1 SMP", + want: true, + }, + { + name: "no_microsoft_marker", + content: "Linux version 6.1.0-23-amd64 (debian-kernel@lists.debian.org)", + want: false, + }, + { + name: "empty_content", + content: "", + want: false, + }, + { + name: "partial_word_micah", + content: "Linux version 5.15.0 (micah@kernel.org)", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reader := func(_ string) ([]byte, error) { + return []byte(tt.content), nil + } + assert.Equal(t, tt.want, isWSL(reader)) + }) + } +} + +func TestIsWSLReadError(t *testing.T) { + t.Parallel() + + reader := func(_ string) ([]byte, error) { + return nil, fmt.Errorf("permission denied") + } + + assert.False(t, isWSL(reader)) +} + +func TestRunningOnWSL(t *testing.T) { + t.Parallel() + + // smoke test: verify runningOnWSL doesn't panic and returns a bool. + result := runningOnWSL() + assert.IsType(t, false, result) +} From e56b2d614adc0fbede213e70ac46f579671d4c8a Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 16:12:42 +0530 Subject: [PATCH 14/58] feat(cmd): Add -b,--browse flag --- cmd/cli.go | 11 ++++++++--- cmd/diagnostic.go | 3 +++ cmd/diagnostic_test.go | 5 +++++ cmd/parse.go | 13 +++++++++++++ cmd/parse_test.go | 22 ++++++++++++++++++++++ cmd/validate.go | 3 +++ cmd/validate_test.go | 22 +++++++++++++++++++++- 7 files changed, 75 insertions(+), 4 deletions(-) diff --git a/cmd/cli.go b/cmd/cli.go index d1ebe95..b4bff04 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -19,6 +19,9 @@ type CLI struct { // Search requests a keyword search across pages. Search string + // Browse requests opening the page in the default web browser. + Browse bool + // ListPlatforms requests listing available platforms. ListPlatforms bool @@ -60,9 +63,6 @@ type CLI struct { // LongOptions requests displaying long option forms. LongOptions bool - // Edit requests displaying a GitHub edit link. - Edit bool - // Offline suppresses automatic cache updates. Offline bool @@ -89,4 +89,9 @@ type CLI struct { // Config specifies an alternative config file path. Config string + + // page actions + + // Edit requests displaying a GitHub edit link. + Edit bool } diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 07d5811..a134e45 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -89,6 +89,9 @@ func activeOps(cli *CLI) []string { if cli.Search != "" { ops = append(ops, "--search ") } + if cli.Browse { + ops = append(ops, "--browse") + } if cli.ListPlatforms { ops = append(ops, "--list-platforms") } diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index a7b8df5..d8be57c 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -161,6 +161,11 @@ func TestActiveOps(t *testing.T) { cli: CLI{Update: true}, want: []string{"--update"}, }, + { + name: "browse", + cli: CLI{Browse: true}, + want: []string{"--browse"}, + }, { name: "search", cli: CLI{Search: "ngi"}, diff --git a/cmd/parse.go b/cmd/parse.go index 36f5b04..bea7c20 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -56,6 +56,19 @@ func parse(args []string) (*CLI, error) { "search for pages containing a keyword", ) + fs.BoolVar( + &cli.Browse, + "b", + false, + "open page in default web browser", + ) + fs.BoolVar( + &cli.Browse, + "browse", + false, + "open page in default web browser", + ) + fs.BoolVar( &cli.ListPlatforms, "list-platforms", diff --git a/cmd/parse_test.go b/cmd/parse_test.go index 67d673d..be15fff 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -39,6 +39,20 @@ func TestParse(t *testing.T) { assert.True(t, cli.ListAll) }, }, + { + name: "browse_short", + args: []string{"-b"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + }, + }, + { + name: "browse_long", + args: []string{"--browse"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + }, + }, { name: "search_short", args: []string{"-s", "ngi"}, @@ -399,6 +413,14 @@ func TestParse(t *testing.T) { }, // error cases + { + name: "browse_with_page_conflict", + args: []string{"-b", "tar"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + }, + }, { name: "two_operations", args: []string{"-u", "-l"}, diff --git a/cmd/validate.go b/cmd/validate.go index b1b0735..fdb9083 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -88,6 +88,9 @@ func (c *CLI) operationCount() int { if c.Search != "" { count++ } + if c.Browse { + count++ + } if c.ListPlatforms { count++ } diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 7b9b00c..603115b 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -46,11 +46,25 @@ func TestValidate(t *testing.T) { cli: CLI{Color: "auto", Update: true, List: true, ListAll: true}, wantErr: true, }, + { + name: "single_operation_browse", + cli: CLI{Color: "auto", Browse: true}, + }, { name: "page_and_update", cli: CLI{Color: "auto", Page: []string{"tar"}, Update: true}, wantErr: true, }, + { + name: "browse_and_page", + cli: CLI{Color: "auto", Browse: true, Page: []string{"tar"}}, + wantErr: true, + }, + { + name: "browse_and_update", + cli: CLI{Color: "auto", Browse: true, Update: true}, + wantErr: true, + }, { name: "valid_color_auto", cli: CLI{Color: "auto", Update: true}, @@ -118,6 +132,11 @@ func TestOperationCount(t *testing.T) { cli: CLI{Search: "ngi"}, want: 1, }, + { + name: "browse", + cli: CLI{Browse: true}, + want: 1, + }, { name: "list_platforms", cli: CLI{ListPlatforms: true}, @@ -166,6 +185,7 @@ func TestOperationCount(t *testing.T) { List: true, ListAll: true, Search: "ngi", + Browse: true, ListPlatforms: true, ListLanguages: true, Info: true, @@ -174,7 +194,7 @@ func TestOperationCount(t *testing.T) { GenConfig: true, ConfigPath: true, }, - want: 12, + want: 13, }, } From 4971cbca85bb59a81f747278802f72debfc2326a Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 16:40:51 +0530 Subject: [PATCH 15/58] feat(cmd): Require a page argument for -b,--browse --- cmd/diagnostic.go | 4 ++-- cmd/diagnostic_test.go | 4 ++-- cmd/parse_test.go | 20 +++++++++++++++----- cmd/validate.go | 10 +++++++++- cmd/validate_test.go | 16 ++++++++-------- 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index a134e45..7a2ccda 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -74,7 +74,7 @@ func flagDisplay(name string) string { // activeOps returns display names for all active operations in cli. func activeOps(cli *CLI) []string { var ops []string - if len(cli.Page) > 0 { + if len(cli.Page) > 0 && !cli.Browse { ops = append(ops, "[PAGE]...") } if cli.Update { @@ -90,7 +90,7 @@ func activeOps(cli *CLI) []string { ops = append(ops, "--search ") } if cli.Browse { - ops = append(ops, "--browse") + ops = append(ops, "--browse [PAGE]...") } if cli.ListPlatforms { ops = append(ops, "--list-platforms") diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index d8be57c..f410cdf 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -163,8 +163,8 @@ func TestActiveOps(t *testing.T) { }, { name: "browse", - cli: CLI{Browse: true}, - want: []string{"--browse"}, + cli: CLI{Browse: true, Page: []string{"tar"}}, + want: []string{"--browse [PAGE]..."}, }, { name: "search", diff --git a/cmd/parse_test.go b/cmd/parse_test.go index be15fff..b153a62 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -41,16 +41,18 @@ func TestParse(t *testing.T) { }, { name: "browse_short", - args: []string{"-b"}, + args: []string{"-b", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) }, }, { name: "browse_long", - args: []string{"--browse"}, + args: []string{"--browse", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) }, }, { @@ -414,11 +416,19 @@ func TestParse(t *testing.T) { // error cases { - name: "browse_with_page_conflict", - args: []string{"-b", "tar"}, + name: "browse_without_page", + args: []string{"-b"}, wantErr: true, errCheck: func(t *testing.T, err error) { - assert.ErrorContains(t, err, "cannot be used with") + assert.ErrorContains(t, err, "requires a page argument") + }, + }, + { + name: "browse_with_page", + args: []string{"-b", "tar"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Browse) + assert.Equal(t, []string{"tar"}, cli.Page) }, }, { diff --git a/cmd/validate.go b/cmd/validate.go index fdb9083..3fa856a 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -66,6 +66,14 @@ func validate(cli *CLI) error { return fmtConflictError(cli) } + // browse requires a page argument + if cli.Browse && len(cli.Page) == 0 { + return fmtUsage( + "flag %s requires a page argument", + termcolor.Sprint("bold blue", "--browse"), + ) + } + return nil } @@ -73,7 +81,7 @@ func validate(cli *CLI) error { func (c *CLI) operationCount() int { count := 0 - if len(c.Page) > 0 { + if len(c.Page) > 0 && !c.Browse { count++ } if c.Update { diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 603115b..8ffa57d 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -47,8 +47,8 @@ func TestValidate(t *testing.T) { wantErr: true, }, { - name: "single_operation_browse", - cli: CLI{Color: "auto", Browse: true}, + name: "browse_with_page", + cli: CLI{Color: "auto", Browse: true, Page: []string{"tar"}}, }, { name: "page_and_update", @@ -56,13 +56,13 @@ func TestValidate(t *testing.T) { wantErr: true, }, { - name: "browse_and_page", - cli: CLI{Color: "auto", Browse: true, Page: []string{"tar"}}, + name: "browse_and_update", + cli: CLI{Color: "auto", Browse: true, Update: true}, wantErr: true, }, { - name: "browse_and_update", - cli: CLI{Color: "auto", Browse: true, Update: true}, + name: "browse_without_page", + cli: CLI{Color: "auto", Browse: true}, wantErr: true, }, { @@ -134,7 +134,7 @@ func TestOperationCount(t *testing.T) { }, { name: "browse", - cli: CLI{Browse: true}, + cli: CLI{Browse: true, Page: []string{"tar"}}, want: 1, }, { @@ -194,7 +194,7 @@ func TestOperationCount(t *testing.T) { GenConfig: true, ConfigPath: true, }, - want: 13, + want: 12, }, } From 5caeeff21d56f9d11925cecc739228740b8b1493 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 16:45:53 +0530 Subject: [PATCH 16/58] feat(render): Add BuildViewURL --- internal/render/edit.go | 16 ++++++++++++++ internal/render/edit_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/render/edit.go b/internal/render/edit.go index 9b63225..1886ca5 100644 --- a/internal/render/edit.go +++ b/internal/render/edit.go @@ -40,6 +40,22 @@ func (r *Renderer) renderPageEditLink(p *Page) error { return r.renderEditLink(r.w, url) } +// BuildViewURL returns the GitHub blob URL for a tldr page. +// The URL is constructed from the page's file path. +func BuildViewURL(path string) string { + if path != "" { + page := pathutil.PageName(path) + platform := pathutil.PagePlatform(path) + return fmt.Sprintf( + "https://github.com/tldr-pages/tldr/blob/main/pages/%s/%s.md", + platform, + page, + ) + } + + return "" +} + // buildEditURL returns the GitHub edit URL for a tldr page. // The URL is constructed from the page's file path. func buildEditURL(path string) string { diff --git a/internal/render/edit_test.go b/internal/render/edit_test.go index 018059b..b513823 100644 --- a/internal/render/edit_test.go +++ b/internal/render/edit_test.go @@ -174,3 +174,46 @@ func TestBuildEditURL(t *testing.T) { }) } } + +func TestBuildViewURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + want string + }{ + { + name: "constructs from path", + path: "/pages/common/tar.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/common/tar.md", + }, + { + name: "linux platform extracted correctly", + path: "/pages/linux/apt.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/linux/apt.md", + }, + { + name: "windows platform extracted correctly", + path: "/pages/windows/dir.md", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/windows/dir.md", + }, + { + name: "empty path returns empty", + path: "", + want: "", + }, + { + name: "path without .md extension adds .md", + path: "/pages/common/some-page", + want: "https://github.com/tldr-pages/tldr/blob/main/pages/common/some-page.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildViewURL(tt.path) + assert.Equal(t, tt.want, got) + }) + } +} From b7049485792dced109f2e2204f78f800c5ba1d6e Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 17:30:58 +0530 Subject: [PATCH 17/58] feat(app): Implement -b,--browse --- internal/app/app.go | 2 ++ internal/app/page.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index f918987..7b1a160 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -115,6 +115,8 @@ func (a *App) dispatch(cli *cmd.CLI) int { return a.genConfig() case cli.ConfigPath: return a.configPath() + case cli.Browse: + return a.browsePage(cli) case len(cli.Page) > 0: return a.lookupAndRenderPage(cli) default: diff --git a/internal/app/page.go b/internal/app/page.go index f85dc65..31b7042 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/TheRootDaemon/tlgc/browser" "github.com/TheRootDaemon/tlgc/cmd" "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/internal/config" @@ -64,6 +65,51 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { return 0 } +// browsePage looks up a page and opens it in the default web browser. +// It does not render the page content to the terminal. +// Returns 0 on success, 1 on error. +func (a *App) browsePage(cli *cmd.CLI) int { + p := a.resolvePlatform(cli.Platform) + langs := a.resolveLanguages(cli.Languages) + c := cache.New() + + if !cli.Offline { + cfg := config.Cache() + if cfg.AutoUpdate && c.NeedsUpdate(cfg.MaxAge) { + client := upstream.New() + if err := c.Update(context.Background(), langs, client); err != nil { + logger.Warn("auto-update failed: %v", err) + } + } + } + + 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, _, err := a.selectPage(results, query, p) + if err != nil { + logger.Error("%v", err) + return 1 + } + + url := render.BuildViewURL(pagePath) + if url == "" { + logger.Error("could not build URL for page") + return 1 + } + + logger.Info("opening page in browser") + if err := browser.Open(url); err != nil { + logger.Error("failed to open browser: %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. From e1e7bcd4644b2ffcac0b14c4f95a8ecf4ad699de Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 27 Jul 2026 17:53:40 +0530 Subject: [PATCH 18/58] tests: Add cases for darwin, windows. Skip irrelevant tests --- browser/browse_test.go | 4 ++++ browser/open_test.go | 29 +++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/browser/browse_test.go b/browser/browse_test.go index 61f9ab4..455caf0 100644 --- a/browser/browse_test.go +++ b/browser/browse_test.go @@ -1,6 +1,7 @@ package browser import ( + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -36,6 +37,9 @@ func TestRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" && tt.command == "echo" { + t.Skip("echo is not a standalone executable on Windows") + } err := browse(tt.command, tt.args...) if tt.wantErr { assert.Error(t, err) diff --git a/browser/open_test.go b/browser/open_test.go index 8ddb9eb..bd40855 100644 --- a/browser/open_test.go +++ b/browser/open_test.go @@ -115,9 +115,34 @@ func TestOpen(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "no display server detected") } - default: + case "darwin": + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + + err := Open("https://example.com") + assert.NoError(t, err) + assert.Equal( + t, + []call{{name: "open", args: []string{"https://example.com"}}}, + calls, + ) + case "windows": + var calls []call + mockBrowse(t, func(name string, args ...string) error { + calls = append(calls, call{name, args}) + return nil + }) + err := Open("https://example.com") - assert.Error(t, err) + assert.NoError(t, err) + assert.Equal( + t, + []call{{name: "explorer.exe", args: []string{"https://example.com"}}}, + calls, + ) } } From b1f80f89c9b6878e6ec6584176175ba98a2d63ad Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 28 Jul 2026 12:27:45 +0530 Subject: [PATCH 19/58] chore: Add help string for -b,--browse --- cmd/help.go | 6 ++++++ completions/_tlgc | 1 + completions/tlgc.bash | 4 ++-- completions/tlgc.fish | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/help.go b/cmd/help.go index c5c6622..3d8b182 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -62,6 +62,12 @@ func printFlags() { long: "--search", arg: "", description: "Search for pages containing a keyword", }, + { + short: "-b", + long: "--browse", + arg: "[PAGE]...", + description: "Open page in the default web browser", + }, { long: "--list-platforms", description: "List available platforms", diff --git a/completions/_tlgc b/completions/_tlgc index 4b72000..ce6ec46 100644 --- a/completions/_tlgc +++ b/completions/_tlgc @@ -21,6 +21,7 @@ _tlgc() { {-l,--list}"[List all pages in the current platform]" \ {-a,--list-all}"[List all pages]" \ {-s,--search}"[Search for pages containing a keyword]" \ + {-b,--browse}"[Open page in the default web browser]" \ --list-platforms"[List available platforms]" \ --list-languages"[List installed languages]" \ {-i,--info}"[Show cache information]" \ diff --git a/completions/tlgc.bash b/completions/tlgc.bash index 2aa7896..08cfcb2 100644 --- a/completions/tlgc.bash +++ b/completions/tlgc.bash @@ -4,8 +4,8 @@ _tlgc() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" - local opts="-u -l -a -s -i -r -p -L -o -c -R -q -v -h \ - --update --list --list-all --search --list-platforms --list-languages \ + local opts="-u -l -a -s -b -i -r -p -L -o -c -R -q -v -h \ + --update --list --list-all --search --browse --list-platforms --list-languages \ --info --render --clean-cache --gen-config --config-path --platform \ --language --short-options --long-options --edit --offline --compact \ --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" diff --git a/completions/tlgc.fish b/completions/tlgc.fish index 1c9cf48..b01d713 100644 --- a/completions/tlgc.fish +++ b/completions/tlgc.fish @@ -2,6 +2,7 @@ complete -c tlgc -s u -l update -d "Update the cache" complete -c tlgc -s l -l list -d "List all pages in the current platform" complete -c tlgc -s a -l list-all -d "List all pages" complete -c tlgc -s s -l search -d "Search for pages containing a keyword" +complete -c tlgc -s b -l browse -d "Open page in the default web browser" complete -c tlgc -l list-platforms -d "List available platforms" complete -c tlgc -l list-languages -d "List installed languages" complete -c tlgc -s i -l info -d "Show cache information" From 6c6bc11fe16c30b91a126c8b3495920559370de2 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 28 Jul 2026 12:40:03 +0530 Subject: [PATCH 20/58] cmd: Fix no-op cases --- cmd/cli.go | 3 +++ cmd/parse_test.go | 8 ++++++++ cmd/validate.go | 4 ++++ cmd/validate_test.go | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/cmd/cli.go b/cmd/cli.go index b4bff04..79e0c0a 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -94,4 +94,7 @@ type CLI struct { // Edit requests displaying a GitHub edit link. Edit bool + + // HasArgs indicates whether any command-line arguments were provided. + HasArgs bool } diff --git a/cmd/parse_test.go b/cmd/parse_test.go index b153a62..723b3c2 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -413,6 +413,14 @@ func TestParse(t *testing.T) { args: []string{}, wantErr: false, }, + { + name: "only_modifiers_no_operation", + args: []string{"--compact", "--edit", "--offline", "--no-raw"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "no operation specified") + }, + }, // error cases { diff --git a/cmd/validate.go b/cmd/validate.go index 3fa856a..9a4f70e 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -19,6 +19,7 @@ func Validate(cli *CLI, fs *flag.FlagSet, args []string) (*CLI, error) { } cli.Page = fs.Args() + cli.HasArgs = len(args) > 0 if err := validate(cli); err != nil { return nil, err @@ -59,6 +60,9 @@ func validate(cli *CLI) error { // validate that exactly one operation is active ops := cli.operationCount() if ops == 0 { + if cli.HasArgs { + return fmtUsage("no operation specified") + } help() return nil } diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 8ffa57d..d0e570b 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -31,6 +31,11 @@ func TestValidate(t *testing.T) { cli: CLI{Color: "auto"}, wantErr: false, }, + { + name: "no_operations_with_modifiers", + cli: CLI{Color: "auto", HasArgs: true, Compact: true, Edit: true}, + wantErr: true, + }, { name: "invalid_color", cli: CLI{Color: "invalid", Update: true}, From 4d01007fe0a053698f2ae4d3dc92c0814d1574f2 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 30 Jul 2026 23:23:07 +0530 Subject: [PATCH 21/58] bin: tlgc -> tldr --- cmd/diagnostic.go | 2 +- cmd/diagnostic_test.go | 2 +- cmd/parse.go | 2 +- completions/{_tlgc => _tldr} | 12 +++++----- completions/{tlgc.bash => tldr.bash} | 10 ++++---- completions/tldr.fish | 35 ++++++++++++++++++++++++++++ completions/tlgc.fish | 35 ---------------------------- 7 files changed, 49 insertions(+), 49 deletions(-) rename completions/{_tlgc => _tldr} (90%) rename completions/{tlgc.bash => tldr.bash} (81%) create mode 100644 completions/tldr.fish delete mode 100644 completions/tlgc.fish diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 7a2ccda..f5e1989 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -56,7 +56,7 @@ func fmtUsage(format string, args ...any) error { usage := fmt.Sprintf( "\n\n%s %s [OPTIONS] [PAGE]...\n\nFor more information, try %s.", termcolor.Sprint("bold underline", "Usage:"), - termcolor.Sprint("bold", "tlgc"), + termcolor.Sprint("bold", "tldr"), termcolor.Sprint("bold blue", "--help"), ) return fmt.Errorf(format+usage, args...) diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index f410cdf..5b4b09b 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -113,7 +113,7 @@ func TestFmtUsage(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "test message 42") assert.Contains(t, err.Error(), "Usage:") - assert.Contains(t, err.Error(), "tlgc") + assert.Contains(t, err.Error(), "tldr") assert.Contains(t, err.Error(), "--help") } diff --git a/cmd/parse.go b/cmd/parse.go index bea7c20..1f72770 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -21,7 +21,7 @@ func parse(args []string) (*CLI, error) { cli := &CLI{} - fs := flag.NewFlagSet("tlgc", flag.ContinueOnError) + fs := flag.NewFlagSet("tldr", flag.ContinueOnError) // operations fs.BoolVar(&cli.Update, "u", false, "update the cache") diff --git a/completions/_tlgc b/completions/_tldr similarity index 90% rename from completions/_tlgc rename to completions/_tldr index ce6ec46..5fdc7ef 100644 --- a/completions/_tlgc +++ b/completions/_tldr @@ -1,21 +1,21 @@ -#compdef tlgc +#compdef tldr _pages() { - local -a pages=(${(uonzf)"$(tlgc --offline --list-all 2> /dev/null)"//:/\\:}) + local -a pages=(${(uonzf)"$(tldr --offline --list-all 2> /dev/null)"//:/\\:}) _describe "PAGE" pages } _languages() { - local -a languages=(${(uonzf)"$(tlgc --offline --list-languages 2> /dev/null)"//:/\\:}) + local -a languages=(${(uonzf)"$(tldr --offline --list-languages 2> /dev/null)"//:/\\:}) _describe "LANGUAGE_CODE" languages } _platforms() { - local -a platforms=(${(uonzf)"$(tlgc --offline --list-platforms 2> /dev/null)"//:/\\:}) + local -a platforms=(${(uonzf)"$(tldr --offline --list-platforms 2> /dev/null)"//:/\\:}) _describe "PLATFORM" platforms } -_tlgc() { +_tldr() { _arguments -s -S \ {-u,--update}"[Update the cache]" \ {-l,--list}"[List all pages in the current platform]" \ @@ -48,4 +48,4 @@ _tlgc() { '*:PAGE:_pages' } -_tlgc +_tldr diff --git a/completions/tlgc.bash b/completions/tldr.bash similarity index 81% rename from completions/tlgc.bash rename to completions/tldr.bash index 08cfcb2..a7173b2 100644 --- a/completions/tlgc.bash +++ b/completions/tldr.bash @@ -1,6 +1,6 @@ # shellcheck shell=bash -_tlgc() { +_tldr() { local cur="${COMP_WORDS[COMP_CWORD]}" local prev="${COMP_WORDS[COMP_CWORD-1]}" @@ -21,12 +21,12 @@ _tlgc() { --color) mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur");; -p|--platform) - mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-platforms 2> /dev/null)" -- "$cur");; + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-platforms 2> /dev/null)" -- "$cur");; -L|--language) - mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-languages 2> /dev/null)" -- "$cur");; + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-languages 2> /dev/null)" -- "$cur");; *) - mapfile -t COMPREPLY < <(compgen -W "$(tlgc --offline --list-all 2> /dev/null)" -- "$cur");; + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-all 2> /dev/null)" -- "$cur");; esac } -complete -o bashdefault -F _tlgc tlgc +complete -o bashdefault -F _tldr tldr diff --git a/completions/tldr.fish b/completions/tldr.fish new file mode 100644 index 0000000..5aae6c7 --- /dev/null +++ b/completions/tldr.fish @@ -0,0 +1,35 @@ +complete -c tldr -s u -l update -d "Update the cache" +complete -c tldr -s l -l list -d "List all pages in the current platform" +complete -c tldr -s a -l list-all -d "List all pages" +complete -c tldr -s s -l search -d "Search for pages containing a keyword" +complete -c tldr -s b -l browse -d "Open page in the default web browser" +complete -c tldr -l list-platforms -d "List available platforms" +complete -c tldr -l list-languages -d "List installed languages" +complete -c tldr -s i -l info -d "Show cache information" +complete -c tldr -s r -l render -d "Render the specified tldr page" -r +complete -c tldr -l clean-cache -d "Interactively delete contents of the cache directory" +complete -c tldr -l gen-config -d "Print the default config" +complete -c tldr -l config-path -d "Print the default config path" +complete -c tldr -s p -l platform -d "Specify the platform to use (linux, osx, windows, etc.)" -x -a \ + "(tldr --offline --list-platforms 2> /dev/null)" +complete -c tldr -s L -l language -d "Specify the languages to use" -x -a \ + "(tldr --offline --list-languages 2> /dev/null)" +complete -c tldr -l short-options -d "Display short options wherever possible (e.g. '-s')" +complete -c tldr -l long-options -d "Display long options wherever possible (e.g. '--long')" +complete -c tldr -l edit -d "Display a link to edit the shown page on GitHub" +complete -c tldr -s o -l offline -d "Do not update the cache, even if it is stale" +complete -c tldr -s c -l compact -d "Strip empty lines from output" +complete -c tldr -l no-compact -d "Do not strip empty lines from output (overrides --compact)" +complete -c tldr -s R -l raw -d "Print pages in raw markdown instead of rendering them" +complete -c tldr -l no-raw -d "Render pages instead of printing raw file contents (overrides --raw)" +complete -c tldr -s q -l quiet -d "Suppress status messages and warnings" +complete -c tldr -l verbose -d "Be more verbose (can be specified twice)" +complete -c tldr -l color -d "Specify when to enable color [default: auto] [possible values: auto, always, never]" -x -a " + auto\t'Display color if standard output is a terminal and NO_COLOR is not set' + always\t'Always display color' + never\t'Never display color' +" +complete -c tldr -l config -d "Specify an alternative path to the config file" -r +complete -c tldr -s v -l version -d "Print version" +complete -c tldr -s h -l help -d "Print help" +complete -c tldr -f -a "(tldr --offline --list-all 2> /dev/null)" diff --git a/completions/tlgc.fish b/completions/tlgc.fish deleted file mode 100644 index b01d713..0000000 --- a/completions/tlgc.fish +++ /dev/null @@ -1,35 +0,0 @@ -complete -c tlgc -s u -l update -d "Update the cache" -complete -c tlgc -s l -l list -d "List all pages in the current platform" -complete -c tlgc -s a -l list-all -d "List all pages" -complete -c tlgc -s s -l search -d "Search for pages containing a keyword" -complete -c tlgc -s b -l browse -d "Open page in the default web browser" -complete -c tlgc -l list-platforms -d "List available platforms" -complete -c tlgc -l list-languages -d "List installed languages" -complete -c tlgc -s i -l info -d "Show cache information" -complete -c tlgc -s r -l render -d "Render the specified tldr page" -r -complete -c tlgc -l clean-cache -d "Interactively delete contents of the cache directory" -complete -c tlgc -l gen-config -d "Print the default config" -complete -c tlgc -l config-path -d "Print the default config path" -complete -c tlgc -s p -l platform -d "Specify the platform to use (linux, osx, windows, etc.)" -x -a \ - "(tlgc --offline --list-platforms 2> /dev/null)" -complete -c tlgc -s L -l language -d "Specify the languages to use" -x -a \ - "(tlgc --offline --list-languages 2> /dev/null)" -complete -c tlgc -l short-options -d "Display short options wherever possible (e.g. '-s')" -complete -c tlgc -l long-options -d "Display long options wherever possible (e.g. '--long')" -complete -c tlgc -l edit -d "Display a link to edit the shown page on GitHub" -complete -c tlgc -s o -l offline -d "Do not update the cache, even if it is stale" -complete -c tlgc -s c -l compact -d "Strip empty lines from output" -complete -c tlgc -l no-compact -d "Do not strip empty lines from output (overrides --compact)" -complete -c tlgc -s R -l raw -d "Print pages in raw markdown instead of rendering them" -complete -c tlgc -l no-raw -d "Render pages instead of printing raw file contents (overrides --raw)" -complete -c tlgc -s q -l quiet -d "Suppress status messages and warnings" -complete -c tlgc -l verbose -d "Be more verbose (can be specified twice)" -complete -c tlgc -l color -d "Specify when to enable color [default: auto] [possible values: auto, always, never]" -x -a " - auto\t'Display color if standard output is a terminal and NO_COLOR is not set' - always\t'Always display color' - never\t'Never display color' -" -complete -c tlgc -l config -d "Specify an alternative path to the config file" -r -complete -c tlgc -s v -l version -d "Print version" -complete -c tlgc -s h -l help -d "Print help" -complete -c tlgc -f -a "(tlgc --offline --list-all 2> /dev/null)" From d526c2079b579c24518a0ac7b58189344f0dee3b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 1 Aug 2026 23:14:52 +0530 Subject: [PATCH 22/58] init(lint): Linting rules --- internal/lint/doc.go | 6 ++++ internal/lint/lint.go | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 internal/lint/doc.go create mode 100644 internal/lint/lint.go diff --git a/internal/lint/doc.go b/internal/lint/doc.go new file mode 100644 index 0000000..4a30970 --- /dev/null +++ b/internal/lint/doc.go @@ -0,0 +1,6 @@ +// Package lint provides a linter for TLDR markdown pages. +// +// It validates both page contents and filenames +// against the official TLDR formatting rules, +// reporting every violation found. +package lint diff --git a/internal/lint/lint.go b/internal/lint/lint.go new file mode 100644 index 0000000..222a326 --- /dev/null +++ b/internal/lint/lint.go @@ -0,0 +1,77 @@ +package lint + +import "fmt" + +// Error represents a single lint violation. +type Error struct { + Code string // e.g. "TLDR001" + Line int // 1-indexed line number; 0 for file-level errors + Description string +} + +// Result holds all lint violations found. +type Result struct { + Errors []Error +} + +// ErrorCodes maps code to human-readable description. +var ErrorCodes = map[string]string{ + "TLDR001": "File should contain no leading whitespace", + "TLDR002": "A single space should precede a sentence", + "TLDR003": "Descriptions should start with a capital letter", + "TLDR004": "Command descriptions should end in a period", + "TLDR005": "Example descriptions should end in a colon with no trailing characters", + "TLDR006": "Command name and description should be separated by an empty line", + "TLDR007": "Example descriptions should be surrounded by empty lines", + "TLDR008": "File should contain no trailing whitespace", + "TLDR009": "Page should contain a newline at end of file", + "TLDR010": "Only Unix-style line endings allowed", + "TLDR011": "Page never contains more than a single empty line", + "TLDR012": "Page should contain no tabs", + "TLDR013": "Title should be alphanumeric with dashes, underscores, spaces or allowed characters", + "TLDR014": "Page should contain no trailing whitespace", + "TLDR015": "Example descriptions should start with a capital letter", + "TLDR016": "Label for information link should be spelled exactly `More information: `", + "TLDR017": "Information link should be surrounded with angle brackets", + "TLDR018": "Page should only include a single information link", + "TLDR019": "Page should only include a maximum of 8 examples", + "TLDR020": "Label for additional notes should be spelled exactly `Note: `", + "TLDR021": "Command example should not begin or end in whitespace", + "TLDR101": "Command description probably not properly annotated", + "TLDR102": "Example description probably not properly annotated", + "TLDR103": "Command example is missing its closing backtick", + "TLDR104": "Example descriptions should prefer infinitive tense (e.g. write) over present (e.g. writes) or gerund (e.g. writing)", + "TLDR105": "There should be only one command per example", + "TLDR106": "Page title should start with a hash ('#')", + "TLDR107": "File name should end with .md extension", + "TLDR108": "File name should not contain whitespace", + "TLDR109": "File name should be lowercase", + "TLDR110": "Command example should not be empty", + "TLDR111": "File name should not contain any Windows-forbidden character", + "TLDR112": "Terms `stdin`, `stdout`, `stderr`, and `regex` should be lowercase and wrapped in backticks", +} + +// addError is a convenience helper used by rules. +func addError(r *Result, code string, line int) { + desc := ErrorCodes[code] + if desc == "" { + desc = code + } + r.Errors = append( + r.Errors, + Error{ + Code: code, + Line: line, + Description: desc, + }, + ) +} + +func (e Error) String() string { + return fmt.Sprintf( + "%s:%d %s", + e.Code, + e.Line, + e.Description, + ) +} From f76906fc3079def7cf0d44443e92a4ad41821f3f Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 1 Aug 2026 23:15:08 +0530 Subject: [PATCH 23/58] feat(lint): Filename rules --- internal/lint/filename_rules.go | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/lint/filename_rules.go diff --git a/internal/lint/filename_rules.go b/internal/lint/filename_rules.go new file mode 100644 index 0000000..37eaeb1 --- /dev/null +++ b/internal/lint/filename_rules.go @@ -0,0 +1,42 @@ +package lint + +import ( + "path/filepath" + "strings" +) + +// checkFileExtension reports an error if filename +// does not use the appropriate extension (.md) +// required for TLDR pages. +func checkFileExtension(filename string, r *Result) { + if filepath.Ext(filename) != ".md" { + addError(r, "TLDR107", 0) + } +} + +// checkFilenameWhitespace reports an error if the filename +// contains spaces or tab characters. +func checkFilenameWhitespace(filename string, r *Result) { + base := filepath.Base(filename) + if strings.ContainsAny(base, " \t") { + addError(r, "TLDR108", 0) + } +} + +// checkFilenameLowercase reports an error if the filename +// contains uppercase letters. +func checkFilenameLowercase(filename string, r *Result) { + base := filepath.Base(filename) + if base != strings.ToLower(base) { + addError(r, "TLDR109", 0) + } +} + +// checkForbiddenFilenameCharacters reports an error if filename +// contains characters that are invalid on Windows filesystems. +func checkForbiddenFilenameCharacters(filename string, r *Result) { + base := filepath.Base(filename) + if strings.ContainsAny(base, `<>:"/\|?*`) { + addError(r, "TLDR111", 0) + } +} From f382f77baf7def76bc44e327078ed05a96fc7c5e Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 00:02:33 +0530 Subject: [PATCH 24/58] docs(lint): Fix docs --- internal/lint/filename_rules.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/lint/filename_rules.go b/internal/lint/filename_rules.go index 37eaeb1..4864b6a 100644 --- a/internal/lint/filename_rules.go +++ b/internal/lint/filename_rules.go @@ -5,7 +5,9 @@ import ( "strings" ) -// checkFileExtension reports an error if filename +// checkFileExtension enforces TLDR107. +// +// It reports an error if filename // does not use the appropriate extension (.md) // required for TLDR pages. func checkFileExtension(filename string, r *Result) { @@ -14,7 +16,9 @@ func checkFileExtension(filename string, r *Result) { } } -// checkFilenameWhitespace reports an error if the filename +// checkFilenameWhitespace enforces TLDR108. +// +// It reports an error if the filename // contains spaces or tab characters. func checkFilenameWhitespace(filename string, r *Result) { base := filepath.Base(filename) @@ -23,7 +27,9 @@ func checkFilenameWhitespace(filename string, r *Result) { } } -// checkFilenameLowercase reports an error if the filename +// checkFilenameLowercase enforces TLDR109. +// +// It reports an error if the filename // contains uppercase letters. func checkFilenameLowercase(filename string, r *Result) { base := filepath.Base(filename) @@ -32,7 +38,9 @@ func checkFilenameLowercase(filename string, r *Result) { } } -// checkForbiddenFilenameCharacters reports an error if filename +// checkForbiddenFilenameCharacters enforces TLDR111. +// +// It reports an error if filename // contains characters that are invalid on Windows filesystems. func checkForbiddenFilenameCharacters(filename string, r *Result) { base := filepath.Base(filename) From 3c000f764f4226a0679cc88c71751829e7a41f0b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 17:34:02 +0530 Subject: [PATCH 25/58] lint: Implement parser --- internal/lint/parse.go | 109 ++++++++++++++++++++++++++++++++ internal/lint/parse_lines.go | 99 +++++++++++++++++++++++++++++ internal/lint/parse_sections.go | 94 +++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 internal/lint/parse.go create mode 100644 internal/lint/parse_lines.go create mode 100644 internal/lint/parse_sections.go diff --git a/internal/lint/parse.go b/internal/lint/parse.go new file mode 100644 index 0000000..1eea528 --- /dev/null +++ b/internal/lint/parse.go @@ -0,0 +1,109 @@ +package lint + +// lineKind classifies a single line of a tldr page. +type lineKind int + +const ( + kindBlank lineKind = iota + kindTitle // starts with # + kindDescription // starts with > + kindExampleDesc // starts with - + kindCommand // starts with ` + kindText // everything else +) + +// parsedLine holds classification results for a single source line. +type parsedLine struct { + // kindBlank, kindTitle, kindDescription, kindExampleDesc, kindCommand, or kindText + kind lineKind + + // 0-indexed line number within the raw content + lineNumber int + + // original line text (without trailing \n) + rawLine string + + // extracted content (text after the marker, or between backticks) + content string + + // for kindCommand: whether a closing backtick was found + hasClosingBacktick bool +} + +// commandSection groups an example description with its command(s). +type commandSection struct { + // example description text (after "- ") + description string + + // 0-indexed line number of the description + descriptionLineNumber int + + // command lines that belong to the example + commands []parsedLine +} + +// parsedPage is the structured representation of a tldr page. +type parsedPage struct { + // original full content + rawContent string + + // every parsed line, in source order + lines []parsedLine + + // first title text (without the leading '#') + title string + + // 0-indexed line number of the title + titleLineNumber int + + // consecutive description lines following the title + descriptions []parsedLine + + // description lines that are "More information: ..." links + infoLinks []parsedLine + + // note lines (unused for now; kept for future rules) + notes []parsedLine + + // example descriptions paired with their commands + exampleSections []commandSection +} + +// parse is the top-level parse entry point. +// +// It returns nil for empty or whitespace-only input. +func parse(raw string) *parsedPage { + lines := parseLines(raw) + if lines == nil { + return nil + } + + return buildPage(raw, lines) +} + +// buildPage groups parsed lines into a structured parsedPage. +func buildPage(raw string, lines []parsedLine) *parsedPage { + p := &parsedPage{ + rawContent: raw, + lines: lines, + } + + titleIndex := indexOfTitle(lines) + if titleIndex < 0 { + // no title means nothing else to group; + // rules can inspect p.lines. + return p + } + p.title = lines[titleIndex].content + p.titleLineNumber = lines[titleIndex].lineNumber + + // descriptions (and any info links among them) follow the title, + // optionally separated by blank lines. + start := nextContentIndex(lines, titleIndex+1) + p.descriptions, p.infoLinks, start = collectDescriptions(lines, start) + + // everything after the descriptions forms example sections. + p.exampleSections = collectExampleSections(lines, start) + + return p +} diff --git a/internal/lint/parse_lines.go b/internal/lint/parse_lines.go new file mode 100644 index 0000000..cb9fb63 --- /dev/null +++ b/internal/lint/parse_lines.go @@ -0,0 +1,99 @@ +package lint + +import "strings" + +// parseLines splits raw content at '\n' +// and classifies each line. +// +// It returns nil if the content is empty or whitespace-only. +func parseLines(raw string) []parsedLine { + if strings.TrimSpace(raw) == "" { + return nil + } + + parts := strings.Split(raw, "\n") + lines := make([]parsedLine, 0, len(parts)) + for lineNumber, part := range parts { + lines = append( + lines, + parseLine(lineNumber, part), + ) + } + + return lines +} + +// parseLine classifies a single source line +// and extracts any relevant content from it. +// +// Command lines record whether a closing backtick was present. +func parseLine(lineNumber int, rawLine string) parsedLine { + pl := parsedLine{ + lineNumber: lineNumber, + rawLine: rawLine, + } + + // classification ignores a possible trailing '\r' (DOS line endings). + s := strings.TrimRight(rawLine, "\r") + + if s == "" || strings.TrimSpace(s) == "" { + pl.kind = kindBlank + return pl + } + + switch s[0] { + case '`': + pl.kind = kindCommand + pl.content, pl.hasClosingBacktick = parseCommandContent(s) + + case '#': + pl.kind = kindTitle + pl.content = strings.TrimSpace(s[1:]) + + case '>': + pl.kind = kindDescription + pl.content = stripMarker(s) + + case '-': + pl.kind = kindExampleDesc + pl.content = stripMarker(s) + + default: + pl.kind = kindText + pl.content = strings.TrimRight(s, " \t") + } + + return pl +} + +// parseCommandContent extracts the value between backticks. +// +// It returns the extracted command text +// and reports whether a closing backtick was found. +func parseCommandContent(s string) (string, bool) { + if s == "`" { + return "", false + } + + if !strings.HasPrefix(s, "`") { + return s, false + } + + inner := s[1:] + if before, _, ok := strings.Cut(inner, "`"); ok { + return before, true + } + + return inner, false +} + +// stripMarker removes the leading marker character +// and any immediately following space +// from a description or example-description line. +func stripMarker(s string) string { + if len(s) > 1 && s[1] == ' ' { + return strings.TrimRight(s[2:], " \t") + } + + return strings.TrimRight(s[1:], " \t") +} diff --git a/internal/lint/parse_sections.go b/internal/lint/parse_sections.go new file mode 100644 index 0000000..6e0c73b --- /dev/null +++ b/internal/lint/parse_sections.go @@ -0,0 +1,94 @@ +package lint + +import "strings" + +// indexOfTitle returns the index of the first title line, +// or -1 if the page has no title. +func indexOfTitle(lines []parsedLine) int { + for i, l := range lines { + if l.kind == kindTitle { + return i + } + } + return -1 +} + +// nextContentIndex returns the index of the first non-blank line at or after i. +func nextContentIndex(lines []parsedLine, i int) int { + for i < len(lines) && lines[i].kind == kindBlank { + i++ + } + return i +} + +// collectDescriptions gathers the consecutive description lines starting at i. +// +// It returns the descriptions, +// the subset that are information links, +// and the index of the first line after the descriptions. +func collectDescriptions(lines []parsedLine, i int) ([]parsedLine, []parsedLine, int) { + descriptions := make([]parsedLine, 0, len(lines)) + infoLinks := make([]parsedLine, 0, len(lines)) + + for i < len(lines) && lines[i].kind == kindDescription { + line := lines[i] + descriptions = append(descriptions, line) + + if isInfoLink(line.content) { + infoLinks = append(infoLinks, line) + } + + i++ + } + + return descriptions, infoLinks, i +} + +// collectExampleSections walks the lines starting at i, +// building one commandSection per example-description line (followed by its commands). +func collectExampleSections(lines []parsedLine, i int) []commandSection { + var sections []commandSection + + for i < len(lines) { + i = nextContentIndex(lines, i) + if i >= len(lines) { + break + } + if lines[i].kind != kindExampleDesc { + // stray text or other line – skip it; + // rules can inspect p.lines. + i++ + continue + } + + var section commandSection + section, i = buildExampleSection(lines, i) + sections = append(sections, section) + } + return sections +} + +// buildExampleSection consumes a single example description +// and the command lines that follow it, +// starting at the description line. +// +// It returns the section and the index of the first line after it. +func buildExampleSection(lines []parsedLine, i int) (section commandSection, next int) { + section = commandSection{ + description: lines[i].content, + descriptionLineNumber: lines[i].lineNumber, + } + + i = nextContentIndex(lines, i+1) + + for i < len(lines) && lines[i].kind == kindCommand { + section.commands = append(section.commands, lines[i]) + i = nextContentIndex(lines, i+1) + } + return section, i +} + +// isInfoLink reports whether a description value looks like an information link. +func isInfoLink(content string) bool { + return strings.HasPrefix(content, "More information: ") +} From e95d823efbc1681dbd757f73778ba9b10b2b88ab Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 17:52:25 +0530 Subject: [PATCH 26/58] lint: Tests for parse --- internal/lint/parse_test.go | 308 ++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 internal/lint/parse_test.go diff --git a/internal/lint/parse_test.go b/internal/lint/parse_test.go new file mode 100644 index 0000000..99daea0 --- /dev/null +++ b/internal/lint/parse_test.go @@ -0,0 +1,308 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + raw := "# App\n\n> Brief description.\n> More information: https://example.com\n\n- Copy files\n\n`cp file file.bak`\n\n- Create a backup\n\n`tar czf backup.tar.gz file`" + + title := parsedLine{ + kind: kindTitle, + lineNumber: 0, + rawLine: "# App", + content: "App", + } + blank_1 := parsedLine{ + kind: kindBlank, + lineNumber: 1, + rawLine: "", + } + desc := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Brief description.", + content: "Brief description.", + } + link := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> More information: https://example.com", + content: "More information: https://example.com", + } + blank_4 := parsedLine{ + kind: kindBlank, + lineNumber: 4, + rawLine: "", + } + copyDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 5, + rawLine: "- Copy files", + content: "Copy files", + } + blank_6 := parsedLine{ + kind: kindBlank, + lineNumber: 6, + rawLine: "", + } + copyCommand := parsedLine{ + kind: kindCommand, + lineNumber: 7, + rawLine: "`cp file file.bak`", + content: "cp file file.bak", hasClosingBacktick: true, + } + blank_8 := parsedLine{ + kind: kindBlank, + lineNumber: 8, + rawLine: "", + } + backupDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 9, + rawLine: "- Create a backup", + content: "Create a backup", + } + blank_10 := parsedLine{ + kind: kindBlank, + lineNumber: 10, + rawLine: "", + } + backupCommand := parsedLine{ + kind: kindCommand, + lineNumber: 11, + rawLine: "`tar czf backup.tar.gz file`", + content: "tar czf backup.tar.gz file", + hasClosingBacktick: true, + } + + tests := []struct { + name string + raw string + want *parsedPage + }{ + { + name: "empty input returns nil", + raw: "", + want: nil, + }, + { + name: "whitespace only input returns nil", + raw: " \n\t ", + want: nil, + }, + { + name: "page without a title", + raw: "some text\n", + want: &parsedPage{ + rawContent: "some text\n", + lines: []parsedLine{ + {kind: kindText, lineNumber: 0, rawLine: "some text", content: "some text"}, + {kind: kindBlank, lineNumber: 1, rawLine: ""}, // trailing newline splits into a blank line + }, + }, + }, + { + name: "full page end to end", + raw: raw, + want: &parsedPage{ + rawContent: raw, + lines: []parsedLine{title, blank_1, desc, link, blank_4, copyDescription, blank_6, copyCommand, blank_8, backupDescription, blank_10, backupCommand}, + title: "App", + titleLineNumber: 0, + descriptions: []parsedLine{desc, link}, + infoLinks: []parsedLine{link}, + exampleSections: []commandSection{ + {description: "Copy files", descriptionLineNumber: 5, commands: []parsedLine{copyCommand}}, + {description: "Create a backup", descriptionLineNumber: 9, commands: []parsedLine{backupCommand}}, + }, + }, + }, + { + name: "crlf page classifies correctly", + raw: "# T\r\n\r\n> D\r\n", + want: &parsedPage{ + rawContent: "# T\r\n\r\n> D\r\n", + lines: []parsedLine{ + {kind: kindTitle, lineNumber: 0, rawLine: "# T\r", content: "T"}, + {kind: kindBlank, lineNumber: 1, rawLine: "\r"}, + {kind: kindDescription, lineNumber: 2, rawLine: "> D\r", content: "D"}, + {kind: kindBlank, lineNumber: 3, rawLine: ""}, + }, + title: "T", + titleLineNumber: 0, + descriptions: []parsedLine{{kind: kindDescription, lineNumber: 2, rawLine: "> D\r", content: "D"}}, + infoLinks: []parsedLine{}, + exampleSections: nil, + }, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := parse(tt.raw) + require.Equal(t, tt.want, got) + }, + ) + } +} + +func TestBuildPage(t *testing.T) { + // lines are hand-built, never via parseLines; + // parseLines regression must not cascade into buildPage failures. + title := parsedLine{ + kind: kindTitle, + lineNumber: 0, + rawLine: "# T", + content: "T", + } + titleMid := parsedLine{ + kind: kindTitle, + lineNumber: 2, + rawLine: "# T", + content: "T", + } + text := parsedLine{ + kind: kindText, + lineNumber: 1, + rawLine: "stray", + content: "stray", + } + descriptionBefore := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> Before", + content: "Before", + } + descriptionAfter := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> After", + content: "After", + } + descriptionOne := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> One", + content: "One", + } + descriptionTwo := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Two", + content: "Two", + } + descriptionOnly := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> D", + content: "D", + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 3, + rawLine: "", + } + blankGap := parsedLine{ + kind: kindBlank, + lineNumber: 1, + rawLine: "", + } + exampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 4, + rawLine: "- Do", + content: "Do", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 5, + rawLine: "`x`", + content: "x", + hasClosingBacktick: true, + } + + tests := []struct { + name string + raw string + lines []parsedLine + want *parsedPage + }{ + { + name: "no title leaves grouping empty", + lines: []parsedLine{text, descriptionAfter}, + want: &parsedPage{ + rawContent: "", + lines: []parsedLine{text, descriptionAfter}, + // title, descriptions, infoLinks, exampleSections all nil: + // never touched when there is no title. + }, + }, + { + name: "title with descriptions and one example", + lines: []parsedLine{title, descriptionOne, descriptionTwo, blank, exampleDescription, command}, + want: &parsedPage{ + rawContent: "", + lines: []parsedLine{title, descriptionOne, descriptionTwo, blank, exampleDescription, command}, + title: "T", + titleLineNumber: 0, + descriptions: []parsedLine{descriptionOne, descriptionTwo}, + infoLinks: []parsedLine{}, // make-backed: non-nil even when empty + exampleSections: []commandSection{ + {description: "Do", descriptionLineNumber: 4, commands: []parsedLine{command}}, + }, + }, + }, + { + name: "blank between title and descriptions is tolerated", + lines: []parsedLine{title, blankGap, descriptionOnly}, + want: &parsedPage{ + rawContent: "", + lines: []parsedLine{title, blankGap, descriptionOnly}, + title: "T", + titleLineNumber: 0, + descriptions: []parsedLine{descriptionOnly}, + infoLinks: []parsedLine{}, + exampleSections: nil, // no examples: bare var stays nil + }, + }, + { + name: "title in the middle ignores earlier lines", + lines: []parsedLine{descriptionBefore, text, titleMid, descriptionAfter}, + want: &parsedPage{ + rawContent: "", + lines: []parsedLine{descriptionBefore, text, titleMid, descriptionAfter}, + title: "T", + titleLineNumber: 2, + descriptions: []parsedLine{descriptionAfter}, // grouping anchors at the first title + infoLinks: []parsedLine{}, + exampleSections: nil, + }, + }, + { + name: "text right after title yields empty descriptions and nil sections", + lines: []parsedLine{title, text}, + want: &parsedPage{ + rawContent: "", + lines: []parsedLine{title, text}, + title: "T", + titleLineNumber: 0, + descriptions: []parsedLine{}, // collectDescriptions is make-backed + infoLinks: []parsedLine{}, + exampleSections: nil, // collectExampleSections is a bare var + }, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := buildPage(tt.raw, tt.lines) + require.Equal(t, tt.want, got) + }, + ) + } +} From 5df07121a9460d8603ad77baae111f9a1a2a4b2f Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 17:56:38 +0530 Subject: [PATCH 27/58] lint: Tests for parse_lines --- internal/lint/parse_lines_test.go | 284 ++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 internal/lint/parse_lines_test.go diff --git a/internal/lint/parse_lines_test.go b/internal/lint/parse_lines_test.go new file mode 100644 index 0000000..680d0cb --- /dev/null +++ b/internal/lint/parse_lines_test.go @@ -0,0 +1,284 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseLines(t *testing.T) { + tests := []struct { + name string + raw string + want []parsedLine + }{ + { + name: "empty input returns nil", + raw: "", + want: nil, + }, + { + name: "whitespace only returns nil", + raw: " \n\t ", + want: nil, + }, + { + name: "single line", + raw: "> Hello", + want: []parsedLine{ + {kind: kindDescription, lineNumber: 0, rawLine: "> Hello", content: "Hello"}, + }, + }, + { + name: "consecutive lines are numbered in order", + raw: "# App\n> D\n`c`", + want: []parsedLine{ + {kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"}, + {kind: kindDescription, lineNumber: 1, rawLine: "> D", content: "D"}, + {kind: kindCommand, lineNumber: 2, rawLine: "`c`", content: "c", hasClosingBacktick: true}, + }, + }, + { + name: "trailing newline yields a trailing blank line", + raw: "> A\n", + want: []parsedLine{ + {kind: kindDescription, lineNumber: 0, rawLine: "> A", content: "A"}, + {kind: kindBlank, lineNumber: 1, rawLine: ""}, + }, + }, + { + name: "crlf endings classify correctly but keep raw", + raw: "> A\r\n`B`\r\n", + want: []parsedLine{ + {kind: kindDescription, lineNumber: 0, rawLine: "> A\r", content: "A"}, + {kind: kindCommand, lineNumber: 1, rawLine: "`B`\r", content: "B", hasClosingBacktick: true}, + {kind: kindBlank, lineNumber: 2, rawLine: ""}, + }, + }, + { + name: "leading blank line is preserved", + raw: "\n# T", + want: []parsedLine{ + {kind: kindBlank, lineNumber: 0, rawLine: ""}, + {kind: kindTitle, lineNumber: 1, rawLine: "# T", content: "T"}, + }, + }, + { + name: "empty middle line is a blank", + raw: "# T\n\n> D", + want: []parsedLine{ + {kind: kindTitle, lineNumber: 0, rawLine: "# T", content: "T"}, + {kind: kindBlank, lineNumber: 1, rawLine: ""}, + {kind: kindDescription, lineNumber: 2, rawLine: "> D", content: "D"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseLines(tt.raw) + require.Equal(t, tt.want, got) + }) + } +} + +func TestParseLine(t *testing.T) { + tests := []struct { + name string + lineNumber int + rawLine string + want parsedLine + }{ + { + name: "empty line is blank", + lineNumber: 3, + rawLine: "", + want: parsedLine{kind: kindBlank, lineNumber: 3, rawLine: ""}, + }, + { + name: "whitespace only line is blank", + lineNumber: 0, + rawLine: " \t ", + want: parsedLine{kind: kindBlank, lineNumber: 0, rawLine: " \t "}, + }, + { + name: "title content is trimmed", + lineNumber: 0, + rawLine: "# App ", + want: parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "# App ", content: "App"}, + }, + { + name: "bare hash is a title with empty content", + lineNumber: 0, + rawLine: "#", + want: parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "#", content: ""}, + }, + { + name: "description strips marker and one space", + lineNumber: 2, + rawLine: "> Brief description.", + want: parsedLine{kind: kindDescription, lineNumber: 2, rawLine: "> Brief description.", content: "Brief description."}, + }, + { + name: "description without marker space", + lineNumber: 0, + rawLine: ">No space", + want: parsedLine{kind: kindDescription, lineNumber: 0, rawLine: ">No space", content: "No space"}, + }, + { + name: "command keeps content and closing flag", + lineNumber: 4, + rawLine: "`cp file file.bak`", + want: parsedLine{kind: kindCommand, lineNumber: 4, rawLine: "`cp file file.bak`", content: "cp file file.bak", hasClosingBacktick: true}, + }, + { + name: "command with missing closing backtick", + lineNumber: 0, + rawLine: "`ls -la", + want: parsedLine{kind: kindCommand, lineNumber: 0, rawLine: "`ls -la", content: "ls -la", hasClosingBacktick: false}, + }, + { + name: "single backtick is an empty unterminated command", + lineNumber: 0, + rawLine: "`", + want: parsedLine{kind: kindCommand, lineNumber: 0, rawLine: "`", content: "", hasClosingBacktick: false}, + }, + { + name: "crlf ending is trimmed for classification but kept in rawLine", + lineNumber: 1, + rawLine: "> Hello\r", + want: parsedLine{kind: kindDescription, lineNumber: 1, rawLine: "> Hello\r", content: "Hello"}, + }, + { + name: "leading whitespace defeats marker classification", + lineNumber: 0, + rawLine: " `ls`", + want: parsedLine{kind: kindText, lineNumber: 0, rawLine: " `ls`", content: " `ls`"}, + }, + { + name: "text line is right trimmed", + lineNumber: 0, + rawLine: "free text \t", + want: parsedLine{kind: kindText, lineNumber: 0, rawLine: "free text \t", content: "free text"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseLine(tt.lineNumber, tt.rawLine) + require.Equal(t, tt.want, got) + }) + } +} + +func TestParseCommandContent(t *testing.T) { + tests := []struct { + name string + in string + wantContent string + wantClosing bool + }{ + { + name: "single backtick only", + in: "`", + wantContent: "", + wantClosing: false, + }, + { + name: "closed command", + in: "`ls -la`", + wantContent: "ls -la", + wantClosing: true, + }, + { + name: "missing closing backtick", + in: "`ls -la", + wantContent: "ls -la", + wantClosing: false, + }, + { + name: "empty closed command", + in: "``", + wantContent: "", + wantClosing: true, + }, + { + name: "stops at the first closing backtick", + in: "`echo `a`", + wantContent: "echo ", + wantClosing: true, + }, + { + name: "no leading backtick returns the input untouched", + in: "ls -la", + wantContent: "ls -la", + wantClosing: false, + }, + { + name: "content after a closing backtick is dropped", + in: "`a`b", + wantContent: "a", + wantClosing: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, closing := parseCommandContent(tt.in) + require.Equal(t, tt.wantContent, content) + require.Equal(t, tt.wantClosing, closing) + }) + } +} + +func TestStripMarker(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "marker plus one space", + in: "> foo", + want: "foo", + }, + { + name: "marker without space", + in: ">foo", + want: "foo", + }, + { + name: "only a single space is removed", + in: "> foo", + want: " foo", + }, + { + name: "trailing spaces and tabs are trimmed", + in: "> foo \t", + want: "foo", + }, + { + name: "bare marker", + in: ">", + want: "", + }, + { + name: "marker plus only a space", + in: "> ", + want: "", + }, + { + name: "dash marker", + in: "- foo", + want: "foo", + }, + { + name: "tab after marker is not a separator", + in: ">\tfoo", + want: "\tfoo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripMarker(tt.in) + require.Equal(t, tt.want, got) + }) + } +} From 2f4bd5409ea1ae61598b4d95bd96f8a28723a6e9 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 18:06:12 +0530 Subject: [PATCH 28/58] lint: Tests for parse_sections --- internal/lint/parse_sections_test.go | 535 +++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 internal/lint/parse_sections_test.go diff --git a/internal/lint/parse_sections_test.go b/internal/lint/parse_sections_test.go new file mode 100644 index 0000000..0b8ad9d --- /dev/null +++ b/internal/lint/parse_sections_test.go @@ -0,0 +1,535 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIndexOfTitle(t *testing.T) { + title := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: "# T", + content: "T", + } + secondTitle := parsedLine{ + kind: kindTitle, + lineNumber: 4, + rawLine: "# U", + content: "U", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> D", + content: "D", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "`c`", + content: "c", + hasClosingBacktick: true, + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 3, + rawLine: "", + } + + tests := []struct { + name string + lines []parsedLine + want int + }{ + { + name: "nil lines", + lines: nil, + want: -1, + }, + { + name: "no title", + lines: []parsedLine{description, command}, + want: -1, + }, + { + name: "title at the start", + lines: []parsedLine{title, description}, + want: 0, + }, + { + name: "title in the middle", + lines: []parsedLine{description, command, title}, + want: 2, + }, + { + name: "two titles: the first wins", + lines: []parsedLine{title, blank, secondTitle}, + want: 0, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := indexOfTitle(tt.lines) + require.Equal(t, tt.want, got) + }, + ) + } +} + +func TestNextContentIndex(t *testing.T) { + title := parsedLine{ + kind: kindTitle, + lineNumber: 0, + rawLine: "# T", + content: "T", + } + text := parsedLine{ + kind: kindText, + lineNumber: 2, + rawLine: "x", + content: "x", + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 1, + rawLine: "", + } + + tests := []struct { + name string + lines []parsedLine + i int + want int + }{ + { + name: "nil lines", + lines: nil, + i: 0, + want: 0, + }, + { + name: "starts on content returns the same index", + lines: []parsedLine{title, blank}, + i: 0, + want: 0, + }, + { + name: "skips leading blanks", + lines: []parsedLine{blank, blank, title}, + i: 0, + want: 2, + }, + { + name: "i beyond len returns i unchanged", + lines: []parsedLine{title}, + i: 5, + want: 5, + }, + { + name: "all-blank suffix returns len", + lines: []parsedLine{title, blank, blank}, + i: 1, + want: 3, + }, + { + name: "stops at any non-blank, including text", + lines: []parsedLine{blank, blank, text}, + i: 0, + want: 2, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := nextContentIndex(tt.lines, tt.i) + require.Equal(t, tt.want, got) + }, + ) + } +} + +func TestCollectDescriptions(t *testing.T) { + description_A := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> A", + content: "A", + } + description_B := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> B", + content: "B", + } + link := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> More information: https://x", + content: "More information: https://x", + } + noSpace := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> More information:", + content: "More information:", + } + lower := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> more information: x", + content: "more information: x", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "`c`", + content: "c", hasClosingBacktick: true, + } + text := parsedLine{ + kind: kindText, + lineNumber: 1, + rawLine: "x", + content: "x", + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 1, + rawLine: "", + } + + tests := []struct { + name string + lines []parsedLine + i int + wantDescription []parsedLine + wantInfo []parsedLine + wantNext int + }{ + { + name: "empty input yields non-nil empty results", + lines: nil, + i: 0, + wantDescription: []parsedLine{}, // make-backed: non-nil even when empty + wantInfo: []parsedLine{}, + wantNext: 0, + }, + { + name: "consecutive run", + lines: []parsedLine{description_A, description_B, command}, + i: 0, + wantDescription: []parsedLine{description_A, description_B}, + wantInfo: []parsedLine{}, + wantNext: 2, + }, + { + name: "stops at the first non-description", + lines: []parsedLine{description_A, text, description_B}, + i: 0, + wantDescription: []parsedLine{description_A}, + wantInfo: []parsedLine{}, + wantNext: 1, + }, + { + name: "a blank line ends the run", + lines: []parsedLine{description_A, blank, description_B}, + i: 0, + wantDescription: []parsedLine{description_A}, + wantInfo: []parsedLine{}, + wantNext: 1, + }, + { + name: "info link is collected and detected", + lines: []parsedLine{link, description_A}, + i: 0, + wantDescription: []parsedLine{link, description_A}, + wantInfo: []parsedLine{link}, + wantNext: 2, + }, + { + name: "exact prefix is required", + lines: []parsedLine{noSpace, lower}, + i: 0, + wantDescription: []parsedLine{noSpace, lower}, + wantInfo: []parsedLine{}, + wantNext: 2, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + description, info, next := collectDescriptions(tt.lines, tt.i) + require.Equal(t, tt.wantDescription, description) + require.Equal(t, tt.wantInfo, info) + require.Equal(t, tt.wantNext, next) + }, + ) + } +} + +func TestCollectExampleSections(t *testing.T) { + exampleDescription_1 := parsedLine{ + kind: kindExampleDesc, + lineNumber: 0, + rawLine: "- A", + content: "A", + } + command_1 := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`a`", + content: "a", + hasClosingBacktick: true, + } + blank_A := parsedLine{ + kind: kindBlank, + lineNumber: 2, + rawLine: "", + } + blank_B := parsedLine{ + kind: kindBlank, + lineNumber: 3, + rawLine: "", + } + exampleDescription_2 := parsedLine{ + kind: kindExampleDesc, + lineNumber: 4, + rawLine: "- B", + content: "B", + } + command_2 := parsedLine{ + kind: kindCommand, + lineNumber: 5, + rawLine: "`b`", + content: "b", + hasClosingBacktick: true, + } + text := parsedLine{ + kind: kindText, + lineNumber: 6, + rawLine: "stray", + content: "stray", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 7, + rawLine: "> D", + content: "D", + } + + tests := []struct { + name string + lines []parsedLine + i int + want []commandSection + }{ + { + name: "no examples returns nil", + lines: nil, + i: 0, + want: nil, // bare var: nil when empty + }, + { + name: "leading blank is skipped", + lines: []parsedLine{blank_A, exampleDescription_1, command_1}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + }, + }, + { + name: "blank gap between sections", + lines: []parsedLine{exampleDescription_1, command_1, blank_A, blank_B, exampleDescription_2, command_2}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + {description: "B", descriptionLineNumber: 4, commands: []parsedLine{command_2}}, + }, + }, + { + name: "stray text lines are skipped", + lines: []parsedLine{exampleDescription_1, command_1, text, exampleDescription_2, command_2}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + {description: "B", descriptionLineNumber: 4, commands: []parsedLine{command_2}}, + }, + }, + { + name: "description after examples is stray", + lines: []parsedLine{exampleDescription_1, command_1, description}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + }, + }, + { + name: "consecutive example descriptions", + lines: []parsedLine{exampleDescription_1, exampleDescription_2, command_1}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0}, // no commands + {description: "B", descriptionLineNumber: 4, commands: []parsedLine{command_1}}, + }, + }, + { + name: "trailing blanks after the last command", + lines: []parsedLine{exampleDescription_1, command_1, blank_A, blank_B}, + i: 0, + want: []commandSection{ + {description: "A", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + }, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := collectExampleSections(tt.lines, tt.i) + require.Equal(t, tt.want, got) + }, + ) + } +} + +func TestBuildExampleSection(t *testing.T) { + description := parsedLine{ + kind: kindExampleDesc, + lineNumber: 0, + rawLine: "- Copy files", + content: "Copy files", + } + command_1 := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`cp a b`", + content: "cp a b", + hasClosingBacktick: true, + } + command_2 := parsedLine{ + kind: kindCommand, + lineNumber: 3, + rawLine: "`scp a b`", + content: "scp a b", + hasClosingBacktick: true, + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 2, + rawLine: "", + } + text := parsedLine{ + kind: kindText, + lineNumber: 4, + rawLine: "stray", + content: "stray", + } + + tests := []struct { + name string + lines []parsedLine + i int + want commandSection + wantNext int + }{ + { + name: "description with one command", + lines: []parsedLine{description, command_1}, + i: 0, + want: commandSection{description: "Copy files", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + wantNext: 2, + }, + { + name: "blank between description and command is tolerated", + lines: []parsedLine{description, blank, command_1}, + i: 0, + want: commandSection{description: "Copy files", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + wantNext: 3, + }, + { + name: "blank between commands is tolerated", + lines: []parsedLine{description, command_1, blank, command_2}, + i: 0, + want: commandSection{description: "Copy files", descriptionLineNumber: 0, commands: []parsedLine{command_1, command_2}}, + wantNext: 4, + }, + { + name: "description with no commands leaves commands nil", + lines: []parsedLine{description, text}, + i: 0, + want: commandSection{description: "Copy files", descriptionLineNumber: 0}, // commands: nil — append never ran + wantNext: 1, + }, + { + name: "stops at the next non-command line without consuming it", + lines: []parsedLine{description, command_1, text}, + i: 0, + want: commandSection{description: "Copy files", descriptionLineNumber: 0, commands: []parsedLine{command_1}}, + wantNext: 2, // next points at text; the caller decides what happens to it + }, + { + name: "line numbers come from the parsed lines themselves", + lines: []parsedLine{ + {kind: kindExampleDesc, lineNumber: 7, rawLine: "- X", content: "X"}, + {kind: kindCommand, lineNumber: 9, rawLine: "`y`", content: "y", hasClosingBacktick: true}, + }, + i: 0, + want: commandSection{ + description: "X", + descriptionLineNumber: 7, + commands: []parsedLine{{kind: kindCommand, lineNumber: 9, rawLine: "`y`", content: "y", hasClosingBacktick: true}}, + }, + wantNext: 2, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got, next := buildExampleSection(tt.lines, tt.i) + require.Equal(t, tt.want, got) + require.Equal(t, tt.wantNext, next) + }, + ) + } +} + +func TestIsInfoLink(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + { + name: "exact prefix", + content: "More information: https://example.com", + want: true, + }, + { + name: "missing trailing space", + content: "More information:", + want: false, + }, + { + name: "lowercase prefix", + content: "more information: https://example.com", + want: false, + }, + { + name: "empty content", + content: "", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isInfoLink(tt.content) + require.Equal(t, tt.want, got) + }) + } +} From 3e2e9eba1293d2b008d2a9f0e40343d878240c8b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 2 Aug 2026 22:30:36 +0530 Subject: [PATCH 29/58] lint: Tests for title_rules --- internal/lint/filename_rules_test.go | 200 +++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 internal/lint/filename_rules_test.go diff --git a/internal/lint/filename_rules_test.go b/internal/lint/filename_rules_test.go new file mode 100644 index 0000000..4cde7b3 --- /dev/null +++ b/internal/lint/filename_rules_test.go @@ -0,0 +1,200 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckFileExtension(t *testing.T) { + tests := []struct { + name string + filename string + wantCode string + }{ + { + name: "md extension passes", + filename: "tldr.md", + wantCode: "", + }, + { + name: "path with md extension passes", + filename: "pages/common/tldr.md", + wantCode: "", + }, + { + name: "dotfile with md extension passes", + filename: ".md", + wantCode: "", + }, + { + name: "non-md extension fails", + filename: "tldr.txt", + wantCode: "TLDR107", + }, + { + name: "no extension fails", + filename: "tldr", + wantCode: "TLDR107", + }, + { + name: "wrong final extension fails", + filename: "tldr.md.txt", + wantCode: "TLDR107", + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkFileExtension(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckFilenameWhitespace(t *testing.T) { + tests := []struct { + name string + filename string + wantCode string + }{ + { + name: "clean name passes", + filename: "tldr.md", + wantCode: "", + }, + { + name: "space in directory only passes", + filename: "my dir/tldr.md", + wantCode: "", + }, + { + name: "space in name fails", + filename: "tldr page.md", + wantCode: "TLDR108", + }, + { + name: "tab in name fails", + filename: "tldr\tpage.md", + wantCode: "TLDR108", + }, + { + name: "leading space fails", + filename: " tldr.md", + wantCode: "TLDR108", + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkFilenameWhitespace(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckFilenameLowercase(t *testing.T) { + tests := []struct { + name string + filename string + wantCode string + }{ + { + name: "lowercase name passes", + filename: "tldr.md", + wantCode: "", + }, + { + name: "path with lowercase name passes", + filename: "pages/tldr.md", + wantCode: "", + }, + { + name: "digits pass", + filename: "tldr1.md", + wantCode: "", + }, + { + name: "uppercase first letter fails", + filename: "Tldr.md", + wantCode: "TLDR109", + }, + { + name: "all uppercase fails", + filename: "TLDR.MD", + wantCode: "TLDR109", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkFilenameLowercase(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) + } +} + +func TestCheckForbiddenFilenameCharacters(t *testing.T) { + tests := []struct { + name string + filename string + wantCode string + }{ + { + name: "clean name passes", + filename: "tldr.md", + wantCode: "", + }, + { + name: "directory separator in path passes", + filename: "dir/tldr.md", + wantCode: "", + }, + { + name: "angle bracket fails", + filename: "tldr Date: Sun, 2 Aug 2026 22:51:24 +0530 Subject: [PATCH 30/58] lint: Title rules --- internal/lint/filename_rules_test.go | 10 -- internal/lint/main_test.go | 11 ++ internal/lint/title_rules.go | 92 +++++++++++ internal/lint/title_rules_test.go | 233 +++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 internal/lint/main_test.go create mode 100644 internal/lint/title_rules.go create mode 100644 internal/lint/title_rules_test.go diff --git a/internal/lint/filename_rules_test.go b/internal/lint/filename_rules_test.go index 4cde7b3..c4ccf55 100644 --- a/internal/lint/filename_rules_test.go +++ b/internal/lint/filename_rules_test.go @@ -188,13 +188,3 @@ func TestCheckForbiddenFilenameCharacters(t *testing.T) { ) } } - -// errorCode returns the code of the first reported error, -// or "" if the result is clean. -// Rules report at most one error, always at line 0. -func errorCode(r *Result) string { - if len(r.Errors) == 0 { - return "" - } - return r.Errors[0].Code -} diff --git a/internal/lint/main_test.go b/internal/lint/main_test.go new file mode 100644 index 0000000..043fb3b --- /dev/null +++ b/internal/lint/main_test.go @@ -0,0 +1,11 @@ +package lint + +// errorCode returns the code of the first reported error, +// or "" if the result is clean. +// Rules report at most one error, always at line 0. +func errorCode(r *Result) string { + if len(r.Errors) == 0 { + return "" + } + return r.Errors[0].Code +} diff --git a/internal/lint/title_rules.go b/internal/lint/title_rules.go new file mode 100644 index 0000000..38c0647 --- /dev/null +++ b/internal/lint/title_rules.go @@ -0,0 +1,92 @@ +package lint + +import ( + "strings" + "unicode" +) + +// checkTitleDescriptionSeparator enforces TLDR006. +// +// It reports an error if the page title and the first command +// description are not separated by at least one blank line. +func checkTitleDescriptionSeparator(p *parsedPage, r *Result) { + if len(p.descriptions) == 0 { + return + } + + firstDescriptionNumber := p.descriptions[0].lineNumber + + // find the position of the title and first description in p.lines. + titleIndex := -1 + descriptionIndex := -1 + for i, l := range p.lines { + if l.lineNumber == p.titleLineNumber { + titleIndex = i + } + if l.lineNumber == firstDescriptionNumber { + descriptionIndex = i + } + } + + if titleIndex < 0 || descriptionIndex < 0 { + return + } + + // there should be at least one blank line between them + // (check the line immediately before the description). + if descriptionIndex-titleIndex < 2 || p.lines[descriptionIndex-1].kind != kindBlank { + addError(r, "TLDR006", p.titleLineNumber) + } +} + +// checkTitleCharsacters enforces TLDR013. +// +// It reports an error if the page title contains characters +// outside the allowed character set or ends with a period, +// except for the special titles "." and " .". +func checkTitleCharacters(p *parsedPage, r *Result) { + if p.title == "" { + return + } + + // check for characters outside the allowed set. + for _, ch := range p.title { + if !isValidTitleRune(ch) { + addError(r, "TLDR013", p.titleLineNumber) + return + } + } + + // title should not end with '.' unless it is '.' or ' .' + if strings.HasSuffix(p.title, ".") && + p.title != "." && + p.title != " ." { + addError(r, "TLDR013", p.titleLineNumber) + } +} + +// checkTitleHash enforces TLDR106. +// +// It reports an error if the page does not contain a title line, +// that is, a line beginning with '#'. +func checkTitleHash(p *parsedPage, r *Result) { + // if the page has no title, error at line 0. + for _, l := range p.lines { + if l.kind == kindTitle { + return + } + } + addError(r, "TLDR106", 0) +} + +// isValidTitleRune reports whether ch is permitted in a page title. +// +// Valid title characters include Unicode letters and digits, +// underscores, spaces, and the punctuation +// permitted by the TLDR page format. +func isValidTitleRune(ch rune) bool { + return unicode.IsLetter(ch) || + unicode.IsDigit(ch) || + ch == '_' || + strings.ContainsRune("+[]{}()!%,^~$:><|?.- ", ch) +} diff --git a/internal/lint/title_rules_test.go b/internal/lint/title_rules_test.go new file mode 100644 index 0000000..ae051ad --- /dev/null +++ b/internal/lint/title_rules_test.go @@ -0,0 +1,233 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckTitleDescriptionSeparator(t *testing.T) { + title := parsedLine{ + kind: kindTitle, + lineNumber: 0, + rawLine: "# App", + content: "App", + } + blank := parsedLine{ + kind: kindBlank, + lineNumber: 1, + rawLine: "", + } + text := parsedLine{ + kind: kindText, + lineNumber: 1, + rawLine: "stray", + content: "stray", + } + description_1 := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> D", + content: "D", + } + description_2 := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> D", + content: "D", + } + description_3 := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> D", + content: "D", + } + + tests := []struct { + name string + lines []parsedLine + descriptions []parsedLine + titleLineNumber int + wantCode string + }{ + { + name: "no descriptions passes", + lines: []parsedLine{title}, + wantCode: "", + }, + { + name: "description immediately after title fails", + lines: []parsedLine{title, description_1}, + descriptions: []parsedLine{description_1}, + titleLineNumber: 0, + wantCode: "TLDR006", + }, + { + name: "one blank line between title and description passes", + lines: []parsedLine{title, blank, description_2}, + descriptions: []parsedLine{description_2}, + titleLineNumber: 0, + wantCode: "", + }, + { + name: "multiple blank lines pass", + lines: []parsedLine{title, blank, blank, description_3}, + descriptions: []parsedLine{description_3}, + titleLineNumber: 0, + wantCode: "", + }, + { + name: "non-blank line between title and description fails", + lines: []parsedLine{title, text, description_2}, + descriptions: []parsedLine{description_2}, + titleLineNumber: 0, + wantCode: "TLDR006", + }, + { + name: "description line not present in lines passes", + lines: []parsedLine{title, blank, description_2}, + descriptions: []parsedLine{description_3}, // lineNumber 3 does not exist + titleLineNumber: 0, + wantCode: "", + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkTitleDescriptionSeparator( + &parsedPage{ + lines: tt.lines, + descriptions: tt.descriptions, + titleLineNumber: tt.titleLineNumber, + }, + r, + ) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckTitleCharacters(t *testing.T) { + tests := []struct { + name string + title string + titleLineNumber int + wantCode string + }{ + {name: "empty title passes", title: "", titleLineNumber: 0, wantCode: ""}, + {name: "plain title passes", title: "App", titleLineNumber: 0, wantCode: ""}, + {name: "letters, digits and punctuation pass", title: "Go 2.0 + Plugins!", titleLineNumber: 1, wantCode: ""}, + {name: "single period passes", title: ".", titleLineNumber: 0, wantCode: ""}, + {name: "space before period passes", title: " .", titleLineNumber: 0, wantCode: ""}, + {name: "invalid character fails", title: "App#", titleLineNumber: 0, wantCode: "TLDR013"}, + {name: "at sign fails", title: "App@", titleLineNumber: 0, wantCode: "TLDR013"}, + {name: "trailing period fails", title: "App.", titleLineNumber: 2, wantCode: "TLDR013"}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkTitleCharacters( + &parsedPage{ + title: tt.title, + titleLineNumber: tt.titleLineNumber, + }, + r, + ) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckTitleHash(t *testing.T) { + title := parsedLine{ + kind: kindTitle, + lineNumber: 2, + rawLine: "# App", + content: "App", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 0, + rawLine: "> D", + content: "D", + } + ccommand := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`c`", + content: "c", + hasClosingBacktick: true, + } + + tests := []struct { + name string + lines []parsedLine + wantCode string + }{ + { + name: "title present passes", + lines: []parsedLine{title}, + wantCode: "", + }, + { + name: "title in the middle passes", + lines: []parsedLine{description, ccommand, title}, + wantCode: "", + }, + { + name: "no title fails", + lines: []parsedLine{description, ccommand}, + wantCode: "TLDR106", + }, + { + name: "empty page fails", + lines: nil, + wantCode: "TLDR106", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkTitleHash(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) + } +} + +func TestIsValidTitleRune(t *testing.T) { + tests := []struct { + name string + in rune + want bool + }{ + {name: "lowercase letter", in: 'a', want: true}, + {name: "uppercase letter", in: 'Z', want: true}, + {name: "digit", in: '7', want: true}, + {name: "underscore", in: '_', want: true}, + {name: "space", in: ' ', want: true}, + {name: "dash", in: '-', want: true}, + {name: "dot", in: '.', want: true}, + {name: "plus", in: '+', want: true}, + {name: "question mark", in: '?', want: true}, + {name: "hash", in: '#', want: false}, + {name: "at sign", in: '@', want: false}, + {name: "ampersand", in: '&', want: false}, + {name: "apostrophe", in: '\'', want: false}, + {name: "slash", in: '/', want: false}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := isValidTitleRune(tt.in) + require.Equal(t, tt.want, got) + }, + ) + } +} From 8c2077437ec677cf216dc310bc8186b6a1ee3a2d Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 4 Aug 2026 19:44:09 +0530 Subject: [PATCH 31/58] lint: File rules --- internal/lint/file_rules.go | 129 +++++++++++++++ internal/lint/file_rules_test.go | 261 +++++++++++++++++++++++++++++++ internal/lint/main_test.go | 14 ++ 3 files changed, 404 insertions(+) create mode 100644 internal/lint/file_rules.go create mode 100644 internal/lint/file_rules_test.go diff --git a/internal/lint/file_rules.go b/internal/lint/file_rules.go new file mode 100644 index 0000000..de69dc5 --- /dev/null +++ b/internal/lint/file_rules.go @@ -0,0 +1,129 @@ +package lint + +import "strings" + +// checkLeadingWhitespace enforces TLDR001. +// +// It reports an error if the first non-blank line of the page +// starts with a space or a tab, +// or if the page starts with blank lines at all. +func checkLeadingWhitespace(p *parsedPage, r *Result) { + for i, l := range p.lines { + if l.kind == kindBlank { + continue + } + + // first non-blank line: check for leading space/tab on the line itself. + if len(l.rawLine) > 0 && + (l.rawLine[0] == ' ' || l.rawLine[0] == '\t') { + addError(r, "TLDR001", l.lineNumber) + } else if i > 0 { + // leading blank line triggers TLDR001. + addError(r, "TLDR001", 0) + } + + break + } +} + +// checkSpaceAfterPrefix enforces TLDR002. +// +// It reports an error if a title, description, or example description +// line does not have exactly one space +// after its marker ('#', '>', '-'). +func checkSpaceAfterPrefix(p *parsedPage, r *Result) { + for _, l := range p.lines { + switch l.kind { + case kindTitle, kindDescription, kindExampleDesc: + if len(l.rawLine) > 1 && l.rawLine[1] != ' ' { + addError(r, "TLDR002", l.lineNumber) + } + } + } +} + +// checkNoTrailingWhitespaceAtEOF enforces TLDR008. +// +// It reports an error if the page ends with trailing whitespace, +// that is, with more than the single terminating newline. +func checkNoTrailingWhitespaceAtEOF(p *parsedPage, r *Result) { + trimmed := strings.TrimRight(p.rawContent, " \t\r\n") + trailing := p.rawContent[len(trimmed):] + + if before, ok := strings.CutSuffix(trailing, "\n"); ok { + trailing = before + trailing = strings.TrimSuffix(trailing, "\r") + } + + if strings.TrimRight(trailing, " \t") == "" { + return + } + + line := 0 + for _, l := range p.lines { + if l.kind != kindBlank { + line = l.lineNumber + } + } + + addError(r, "TLDR008", line+1) +} + +// checkEndsWithNewline enforces TLDR009. +// +// It reports an error if the page does not end with a newline. +func checkEndsWithNewline(p *parsedPage, r *Result) { + if !strings.HasSuffix(p.rawContent, "\n") { + addError(r, "TLDR009", 0) + } +} + +// checkUnixLineEndings enforces TLDR010. +// +// It reports an error if the page contains carriage returns, +// that is, non-Unix (CRLF or CR) line endings. +func checkUnixLineEndings(p *parsedPage, r *Result) { + if strings.Contains(p.rawContent, "\r") { + addError(r, "TLDR010", 0) + } +} + +// checkConsecutiveBlankLines enforces TLDR011. +// +// It reports an error for every blank line that directly +// follows another blank line. +func checkConsecutiveBlankLines(p *parsedPage, r *Result) { + count := 0 + for _, l := range p.lines { + if l.kind == kindBlank { + count++ + if count > 1 { + addError(r, "TLDR011", l.lineNumber) + } + } else { + count = 0 + } + } +} + +// checkNoTabs enforces TLDR012. +// +// It reports an error if the page contains any tab character. +func checkNoTabs(p *parsedPage, r *Result) { + if strings.Contains(p.rawContent, "\t") { + addError(r, "TLDR012", 0) + } +} + +// checkTrailingWhitespace enforces TLDR014. +// +// It reports an error for every line that ends with a space or a tab. +func checkTrailingWhitespace(p *parsedPage, r *Result) { + for _, l := range p.lines { + s := strings.TrimRight(l.rawLine, "\r") + if len(s) > 0 && + (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + addError(r, "TLDR014", l.lineNumber) + } + } +} diff --git a/internal/lint/file_rules_test.go b/internal/lint/file_rules_test.go new file mode 100644 index 0000000..290e07a --- /dev/null +++ b/internal/lint/file_rules_test.go @@ -0,0 +1,261 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckLeadingWhitespace(t *testing.T) { + blank := parsedLine{kind: kindBlank, lineNumber: 0, rawLine: ""} + title := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "# App", content: "App"} + leadingSpace := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: " # App", content: "App"} + leadingTab := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "\t# App", content: "App"} + + tests := []struct { + name string + lines []parsedLine + wantCode string + }{ + {name: "clean page passes", lines: []parsedLine{title}, wantCode: ""}, + {name: "leading space fails", lines: []parsedLine{leadingSpace}, wantCode: "TLDR001"}, + {name: "leading tab fails", lines: []parsedLine{leadingTab}, wantCode: "TLDR001"}, + {name: "leading blank line fails", lines: []parsedLine{blank, title}, wantCode: "TLDR001"}, + {name: "empty page passes", lines: nil, wantCode: ""}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkLeadingWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckSpaceAfterPrefix(t *testing.T) { + title := parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"} + noSpaceTitle := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "#App", content: "App"} + description := parsedLine{kind: kindDescription, lineNumber: 2, rawLine: "> Description.", content: "Description."} + noSpaceDescription := parsedLine{kind: kindDescription, lineNumber: 3, rawLine: ">Description.", content: "Description."} + exampleDescription := parsedLine{kind: kindExampleDesc, lineNumber: 4, rawLine: "- Example:", content: "Example:"} + noSpaceExampleDescription := parsedLine{kind: kindExampleDesc, lineNumber: 5, rawLine: "-Example:", content: "Example:"} + command := parsedLine{kind: kindCommand, lineNumber: 6, rawLine: "`ls`", content: "ls", hasClosingBacktick: true} + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "clean page passes", + lines: []parsedLine{title, description, exampleDescription, command}, + wantCodes: nil, + }, + { + name: "title without space fails", + lines: []parsedLine{noSpaceTitle}, + wantCodes: []string{"TLDR002"}, + }, + { + name: "description without space fails", + lines: []parsedLine{noSpaceDescription}, + wantCodes: []string{"TLDR002"}, + }, + { + name: "example description without space fails", + lines: []parsedLine{noSpaceExampleDescription}, + wantCodes: []string{"TLDR002"}, + }, + { + name: "multiple violations fail", + lines: []parsedLine{noSpaceTitle, noSpaceDescription, noSpaceExampleDescription}, + wantCodes: []string{"TLDR002", "TLDR002", "TLDR002"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkSpaceAfterPrefix(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckNoTrailingWhitespaceAtEOF(t *testing.T) { + tests := []struct { + name string + raw string + wantCodes []string + wantLine int + }{ + {name: "single trailing newline passes", raw: "# App\n", wantCodes: nil}, + {name: "no trailing newline passes", raw: "# App", wantCodes: nil}, + {name: "trailing space on last line passes", raw: "# App ", wantCodes: nil}, + {name: "spaces only no newline passes", raw: "# App ", wantCodes: nil}, + {name: "trailing space then newline passes", raw: "# App \n", wantCodes: nil}, + {name: "one blank line at EOF fails", raw: "# App\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "multiple blank lines at EOF fail", raw: "# App\n\n\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "blank line with space at EOF fails", raw: "# App\n \n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "blank line then trailing space fails", raw: "# App\n\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "whitespace after final newline fails", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "tab after final newline fails", raw: "# App\n\t", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "spaces after final newline fail", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "crlf blank line at EOF fails", raw: "# App\n\r\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "crlf then newline at EOF fails", raw: "# App\r\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + lines := parseLines(tt.raw) + checkNoTrailingWhitespaceAtEOF(&parsedPage{rawContent: tt.raw, lines: lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + if len(r.Errors) > 0 { + require.Equal(t, tt.wantLine, r.Errors[0].Line) + } + }, + ) + } +} + +func TestCheckEndsWithNewline(t *testing.T) { + tests := []struct { + name string + raw string + wantCode string + }{ + {name: "ends with newline passes", raw: "# App\n", wantCode: ""}, + {name: "does not end with newline fails", raw: "# App", wantCode: "TLDR009"}, + {name: "empty page fails", raw: "", wantCode: "TLDR009"}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkEndsWithNewline(&parsedPage{rawContent: tt.raw}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckUnixLineEndings(t *testing.T) { + tests := []struct { + name string + raw string + wantCode string + }{ + {name: "unix line endings pass", raw: "# App\n", wantCode: ""}, + {name: "carriage return fails", raw: "# App\r\n", wantCode: "TLDR010"}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkUnixLineEndings(&parsedPage{rawContent: tt.raw}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckConsecutiveBlankLines(t *testing.T) { + title := parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"} + blank1 := parsedLine{kind: kindBlank, lineNumber: 1, rawLine: ""} + blank2 := parsedLine{kind: kindBlank, lineNumber: 2, rawLine: ""} + blank3 := parsedLine{kind: kindBlank, lineNumber: 3, rawLine: ""} + description := parsedLine{kind: kindDescription, lineNumber: 4, rawLine: "> Description.", content: "Description."} + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "single blank lines pass", + lines: []parsedLine{title, blank1, description}, + wantCodes: nil, + }, + { + name: "two consecutive blank lines fail", + lines: []parsedLine{title, blank1, blank2, description}, + wantCodes: []string{"TLDR011"}, + }, + { + name: "three consecutive blank lines fail twice", + lines: []parsedLine{title, blank1, blank2, blank3, description}, + wantCodes: []string{"TLDR011", "TLDR011"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkConsecutiveBlankLines(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckNoTabs(t *testing.T) { + tests := []struct { + name string + raw string + wantCode string + }{ + {name: "no tabs passes", raw: "# App\n", wantCode: ""}, + {name: "tab fails", raw: "# App\t\n", wantCode: "TLDR012"}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkNoTabs(&parsedPage{rawContent: tt.raw}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckTrailingWhitespace(t *testing.T) { + clean := parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"} + trailingSpace := parsedLine{kind: kindDescription, lineNumber: 1, rawLine: "> Description. ", content: "Description."} + trailingTab := parsedLine{kind: kindCommand, lineNumber: 2, rawLine: "`ls`\t", content: "ls", hasClosingBacktick: true} + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + {name: "clean lines pass", lines: []parsedLine{clean}, wantCodes: nil}, + {name: "trailing space fails", lines: []parsedLine{trailingSpace}, wantCodes: []string{"TLDR014"}}, + {name: "trailing tab fails", lines: []parsedLine{trailingTab}, wantCodes: []string{"TLDR014"}}, + { + name: "multiple trailing whitespace lines fail", + lines: []parsedLine{trailingSpace, trailingTab}, + wantCodes: []string{"TLDR014", "TLDR014"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkTrailingWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} diff --git a/internal/lint/main_test.go b/internal/lint/main_test.go index 043fb3b..281731b 100644 --- a/internal/lint/main_test.go +++ b/internal/lint/main_test.go @@ -9,3 +9,17 @@ func errorCode(r *Result) string { } return r.Errors[0].Code } + +// errorCodes returns the codes of all reported errors, +// in report order. +// It returns nil if the result is clean. +func errorCodes(r *Result) []string { + if len(r.Errors) == 0 { + return nil + } + codes := make([]string, len(r.Errors)) + for i, e := range r.Errors { + codes[i] = e.Code + } + return codes +} From 0bae0deda73d08b1bc656c1e069f3484aff9a2fa Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 6 Aug 2026 22:42:08 +0530 Subject: [PATCH 32/58] lint: Description rules --- internal/lint/description_rules.go | 196 +++++++++++ internal/lint/description_rules_test.go | 417 ++++++++++++++++++++++++ 2 files changed, 613 insertions(+) create mode 100644 internal/lint/description_rules.go create mode 100644 internal/lint/description_rules_test.go diff --git a/internal/lint/description_rules.go b/internal/lint/description_rules.go new file mode 100644 index 0000000..c626b37 --- /dev/null +++ b/internal/lint/description_rules.go @@ -0,0 +1,196 @@ +package lint + +import ( + "regexp" + "strings" + "unicode" +) + +var ( + // descriptionCapitalExceptions lists lowercase words that are allowed + // to start a description despite the TLDR003 capitalization rule. + descriptionCapitalExceptions = map[string]bool{ + "npm": true, + "pnpm": true, + } + + // infoLinkLabelPattern matches the "More information:" label case-insensitively. + infoLinkLabelPattern = regexp.MustCompile(`(?i)^(more\s+info(?:rmation)?:?\s*)`) + + // noteLabelPattern matches an incorrectly formatted "Note:" label. + noteLabelPattern = regexp.MustCompile(`\b(note|NOTE): `) + + // urlPattern matches a URL wrapped in angle brackets. + urlPattern = regexp.MustCompile(`<[^>]+>`) + + // standardTermPattern matches the standard terms + // that must be wrapped in backticks. + // Word boundaries are checked in checkValueForStandardTerms + // because RE2 does not support lookaround assertions. + standardTermPattern = regexp.MustCompile(`(?i)(stdout|stdin|stderr|regex|regular\s+expression|standard\s+(?:input|in|output|out|error|err))`) +) + +// checkDescriptionStartsWithCapital enforces TLDR003. +// +// It reports an error if a description starts with a lower-case +// letter and the first word is not an allowed exception. +func checkDescriptionStartsWithCapital(p *parsedPage, r *Result) { + for _, d := range p.descriptions { + val := strings.TrimSpace(d.content) + if val == "" { + continue + } + + // extract first word. + firstWord := val + if idx := strings.IndexAny(val, " \t"); idx >= 0 { + firstWord = val[:idx] + } + if descriptionCapitalExceptions[firstWord] { + continue + } + + // check if first rune is a lower-case letter. + runes := []rune(val) + if len(runes) > 0 && + unicode.IsLetter(runes[0]) && + unicode.IsLower(runes[0]) { + addError(r, "TLDR003", d.lineNumber) + } + } +} + +// checkDescriptionEndsWithPeriod enforces TLDR004. +// +// It reports an error if a description does not end with a period. +// Information link lines are exempt. +func checkDescriptionEndsWithPeriod(p *parsedPage, r *Result) { + for _, d := range p.descriptions { + if isInfoLink(d.content) { + continue + } + val := strings.TrimRight(d.content, " \t") + if val == "" { + continue + } + + runes := []rune(val) + if runes[len(runes)-1] != '.' { + addError(r, "TLDR004", d.lineNumber) + } + } +} + +// checkInformationLinkLabel enforces TLDR016. +// +// It reports an error if an information link uses any label +// other than the exact string "More information: ". +func checkInformationLinkLabel(p *parsedPage, r *Result) { + for _, d := range p.descriptions { + m := infoLinkLabelPattern.FindStringSubmatch(d.content) + if m != nil && m[1] != "More information: " { + addError(r, "TLDR016", d.lineNumber) + } + } +} + +// checkInformationLinkBrackets enforces TLDR017. +// +// It reports an error if an information link URL is not surrounded by angle brackets. +func checkInformationLinkBrackets(p *parsedPage, r *Result) { + for _, l := range p.infoLinks { + val := l.content + if !strings.Contains(val, "<") || !strings.Contains(val, ">") { + addError(r, "TLDR017", l.lineNumber) + } + } +} + +// checkSingleInformationLink enforces TLDR018. +// +// It reports an error for every information link after the first. +func checkSingleInformationLink(p *parsedPage, r *Result) { + if len(p.infoLinks) > 1 { + for _, l := range p.infoLinks[1:] { + addError(r, "TLDR018", l.lineNumber) + } + } +} + +// checkNoteLabelFormat enforces TLDR020. +// +// It reports an error if a description or example description +// contains a "Note:" label that is not exactly "Note: ". +func checkNoteLabelFormat(p *parsedPage, r *Result) { + for _, d := range p.descriptions { + if noteLabelPattern.MatchString(d.content) { + addError(r, "TLDR020", d.lineNumber) + } + } + for _, sec := range p.exampleSections { + if noteLabelPattern.MatchString(sec.description) { + addError(r, "TLDR020", sec.descriptionLineNumber) + } + } +} + +// checkStandardTermsInBackticks enforces TLDR112. +// +// It reports an error when a standard term +// (stdin, stdout, stderr, regex) +// appears outside backticks in a description or example description. +func checkStandardTermsInBackticks(p *parsedPage, r *Result) { + for _, d := range p.descriptions { + checkValueForStandardTerms( + d.content, + d.lineNumber, + r, + ) + } + for _, sec := range p.exampleSections { + checkValueForStandardTerms( + sec.description, + sec.descriptionLineNumber, + r, + ) + } +} + +// checkValueForStandardTerms checks one text value +// for standard terms that are not wrapped in backticks, +// reporting at most one error per line. +func checkValueForStandardTerms(val string, line int, r *Result) { + // split on backticks: + // even parts are outside backticks, + // odd parts are inside. + for i, part := range strings.Split(val, "`") { + if i%2 == 1 { + continue + } + + part = urlPattern.ReplaceAllString(part, "") + + for _, m := range standardTermPattern.FindAllStringIndex(part, -1) { + if m[0] > 0 && + (part[m[0]-1] == '-' || isWordCharacter(part[m[0]-1])) { + continue + } + + if m[1] < len(part) && + (part[m[1]] == '-' || isWordCharacter(part[m[1]])) { + continue + } + + addError(r, "TLDR112", line) + return // at most one error per line + } + } +} + +// isWordCharacter reports whether b is an ASCII letter, digit, or underscore. +func isWordCharacter(b byte) bool { + return b == '_' || + (b >= 'A' && b <= 'Z') || + (b >= 'a' && b <= 'z') || + (b >= '0' && b <= '9') +} diff --git a/internal/lint/description_rules_test.go b/internal/lint/description_rules_test.go new file mode 100644 index 0000000..e5f4ad1 --- /dev/null +++ b/internal/lint/description_rules_test.go @@ -0,0 +1,417 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckDescriptionStartsWithCapital(t *testing.T) { + upper := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> Upper case.", + content: "Upper case.", + } + lower := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> lower case.", + content: "lower case.", + } + npm := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> npm install.", + content: "npm install.", + } + pnpm := parsedLine{ + kind: kindDescription, + lineNumber: 4, + rawLine: "> pnpm add.", + content: "pnpm add.", + } + empty := parsedLine{ + kind: kindDescription, + lineNumber: 5, + rawLine: ">", + content: "", + } + + tests := []struct { + name string + descriptions []parsedLine + wantCodes []string + }{ + {name: "uppercase start passes", descriptions: []parsedLine{upper}, wantCodes: nil}, + {name: "lowercase start fails", descriptions: []parsedLine{lower}, wantCodes: []string{"TLDR003"}}, + {name: "npm exception passes", descriptions: []parsedLine{npm}, wantCodes: nil}, + {name: "pnpm exception passes", descriptions: []parsedLine{pnpm}, wantCodes: nil}, + {name: "empty description passes", descriptions: []parsedLine{empty}, wantCodes: nil}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkDescriptionStartsWithCapital(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckDescriptionEndsWithPeriod(t *testing.T) { + withPeriod := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> Description.", + content: "Description.", + } + withoutPeriod := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Description", + content: "Description", + } + infoLink := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> More information: ", + content: "More information: ", + } + empty := parsedLine{ + kind: kindDescription, + lineNumber: 4, + rawLine: ">", + content: "", + } + + tests := []struct { + name string + descriptions []parsedLine + wantCodes []string + }{ + {name: "ends with period passes", descriptions: []parsedLine{withPeriod}, wantCodes: nil}, + {name: "missing period fails", descriptions: []parsedLine{withoutPeriod}, wantCodes: []string{"TLDR004"}}, + {name: "info link without period passes", descriptions: []parsedLine{infoLink}, wantCodes: nil}, + {name: "empty description passes", descriptions: []parsedLine{empty}, wantCodes: nil}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkDescriptionEndsWithPeriod(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckInformationLinkLabel(t *testing.T) { + exact := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> More information: ", + content: "More information: ", + } + shortened := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> More info: ", + content: "More info: ", + } + noColon := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> More information ", + content: "More information ", + } + lowercase := parsedLine{ + kind: kindDescription, + lineNumber: 4, + rawLine: "> more information: ", + content: "more information: ", + } + notALink := parsedLine{ + kind: kindDescription, + lineNumber: 5, + rawLine: "> Description.", + content: "Description.", + } + + tests := []struct { + name string + descriptions []parsedLine + wantCodes []string + }{ + {name: "exact label passes", descriptions: []parsedLine{exact}, wantCodes: nil}, + {name: "not a link passes", descriptions: []parsedLine{notALink}, wantCodes: nil}, + {name: "shortened label fails", descriptions: []parsedLine{shortened}, wantCodes: []string{"TLDR016"}}, + {name: "missing colon fails", descriptions: []parsedLine{noColon}, wantCodes: []string{"TLDR016"}}, + {name: "lowercase label fails", descriptions: []parsedLine{lowercase}, wantCodes: []string{"TLDR016"}}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkInformationLinkLabel(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckInformationLinkBrackets(t *testing.T) { + bracketed := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> More information: ", + content: "More information: ", + } + unbracketed := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> More information: https://example.com", + content: "More information: https://example.com", + } + missingOpen := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> More information: https://example.com>", + content: "More information: https://example.com>", + } + + tests := []struct { + name string + infoLinks []parsedLine + wantCodes []string + }{ + {name: "bracketed link passes", infoLinks: []parsedLine{bracketed}, wantCodes: nil}, + {name: "unbracketed link fails", infoLinks: []parsedLine{unbracketed}, wantCodes: []string{"TLDR017"}}, + {name: "missing opening bracket fails", infoLinks: []parsedLine{missingOpen}, wantCodes: []string{"TLDR017"}}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkInformationLinkBrackets(&parsedPage{infoLinks: tt.infoLinks}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckSingleInformationLink(t *testing.T) { + first := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> More information: ", + content: "More information: ", + } + second := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> More information: ", + content: "More information: ", + } + third := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> More information: ", + content: "More information: ", + } + + tests := []struct { + name string + infoLinks []parsedLine + wantCodes []string + }{ + {name: "no link passes", infoLinks: nil, wantCodes: nil}, + {name: "single link passes", infoLinks: []parsedLine{first}, wantCodes: nil}, + {name: "two links fail on extra", infoLinks: []parsedLine{first, second}, wantCodes: []string{"TLDR018"}}, + { + name: "three links fail on extras", + infoLinks: []parsedLine{first, second, third}, + wantCodes: []string{"TLDR018", "TLDR018"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkSingleInformationLink(&parsedPage{infoLinks: tt.infoLinks}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckNoteLabelFormat(t *testing.T) { + description := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> Description.", + content: "Description.", + } + lowercaseNote := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> note: something", + content: "note: something", + } + uppercaseNote := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> NOTE: something", + content: "NOTE: something", + } + exampleWithNote := commandSection{ + description: "note: something", + descriptionLineNumber: 4, + } + + tests := []struct { + name string + descriptions []parsedLine + exampleSections []commandSection + wantCodes []string + }{ + {name: "plain description passes", descriptions: []parsedLine{description}, wantCodes: nil}, + { + name: "lowercase note fails", + descriptions: []parsedLine{lowercaseNote}, + wantCodes: []string{"TLDR020"}, + }, + { + name: "uppercase note fails", + descriptions: []parsedLine{uppercaseNote}, + wantCodes: []string{"TLDR020"}, + }, + { + name: "note in example description fails", + exampleSections: []commandSection{exampleWithNote}, + wantCodes: []string{"TLDR020"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkNoteLabelFormat( + &parsedPage{ + descriptions: tt.descriptions, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckStandardTermsInBackticks(t *testing.T) { + backticked := parsedLine{ + kind: kindDescription, + lineNumber: 1, + rawLine: "> Use `stdout`.", + content: "Use `stdout`.", + } + unbackticked := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Writes to stdout.", + content: "Writes to stdout.", + } + inURL := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> See .", + content: "See .", + } + partOfWord := parsedLine{ + kind: kindDescription, + lineNumber: 4, + rawLine: "> List stdoutstreams.", + content: "List stdoutstreams.", + } + betweenSpans := parsedLine{ + kind: kindDescription, + lineNumber: 5, + rawLine: "> Use `foo` and stdin and `bar`.", + content: "Use `foo` and stdin and `bar`.", + } + multipleSpans := parsedLine{ + kind: kindDescription, lineNumber: 6, + rawLine: "> Use `stdin`, `stdout`, and `stderr`.", + content: "Use `stdin`, `stdout`, and `stderr`.", + } + unclosedBacktick := parsedLine{ + kind: kindDescription, + lineNumber: 7, + rawLine: "> Use `stdin to read.", + content: "Use `stdin to read.", + } + exampleWithTerm := commandSection{ + description: "Send output to stderr:", + descriptionLineNumber: 8, + } + + tests := []struct { + name string + descriptions []parsedLine + exampleSections []commandSection + wantCodes []string + }{ + {name: "backticked term passes", descriptions: []parsedLine{backticked}, wantCodes: nil}, + {name: "term in URL passes", descriptions: []parsedLine{inURL}, wantCodes: nil}, + {name: "term inside word passes", descriptions: []parsedLine{partOfWord}, wantCodes: nil}, + { + name: "multiple backticked terms pass", + descriptions: []parsedLine{multipleSpans}, + wantCodes: nil, + }, + { + name: "unclosed backtick before term passes", + descriptions: []parsedLine{unclosedBacktick}, + wantCodes: nil, + }, + { + name: "unbackticked term fails", + descriptions: []parsedLine{unbackticked}, + wantCodes: []string{"TLDR112"}, + }, + { + name: "term between code spans fails", + descriptions: []parsedLine{betweenSpans}, + wantCodes: []string{"TLDR112"}, + }, + { + name: "term in example description fails", + exampleSections: []commandSection{exampleWithTerm}, + wantCodes: []string{"TLDR112"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkStandardTermsInBackticks( + &parsedPage{ + descriptions: tt.descriptions, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} From a7932cbc9a23ac7190b0fd09e4de94a9fdd4be3e Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 6 Aug 2026 23:07:43 +0530 Subject: [PATCH 33/58] lint: example rules --- internal/lint/example_rules.go | 129 +++++++++ internal/lint/example_rules_test.go | 411 ++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 internal/lint/example_rules.go create mode 100644 internal/lint/example_rules_test.go diff --git a/internal/lint/example_rules.go b/internal/lint/example_rules.go new file mode 100644 index 0000000..1d03e50 --- /dev/null +++ b/internal/lint/example_rules.go @@ -0,0 +1,129 @@ +package lint + +import ( + "regexp" + "strings" + "unicode" +) + +// infinitiveTensePattern matches an example description +// that starts with a gerund ("...ing ") +// or a third-person present-tense verb ("...s "). +var infinitiveTensePattern = regexp.MustCompile(`(^[A-Za-z]{3,}ing )|(^[A-Za-z]+[^usy]s )`) + +// checkExampleDescriptionEndsWithColon enforces TLDR005. +// +// It reports an error if an example description does not end with a colon. +func checkExampleDescriptionEndsWithColon(p *parsedPage, r *Result) { + for _, section := range p.exampleSections { + val := strings.TrimRight(section.description, " \t") + if val == "" { + continue + } + + runes := []rune(val) + if runes[len(runes)-1] != ':' { + addError(r, "TLDR005", section.descriptionLineNumber) + } + } +} + +// checkExampleDescriptionSurroundedByBlankLines enforces TLDR007. +// +// It reports an error if an example description is not separated +// from the surrounding content by blank lines. +func checkExampleDescriptionSurroundedByBlankLines(p *parsedPage, r *Result) { + for _, section := range p.exampleSections { + // find the position of the description in p.lines. + descriptionIdx := lineIndex(p.lines, section.descriptionLineNumber) + if descriptionIdx < 0 { + continue + } + + // check for blank line before description. + if descriptionIdx == 0 || p.lines[descriptionIdx-1].kind != kindBlank { + addError(r, "TLDR007", section.descriptionLineNumber) + } + + if len(section.commands) > 0 { + // find the position of the first command. + firstCommandIdx := lineIndex(p.lines, section.commands[0].lineNumber) + if firstCommandIdx >= 0 && firstCommandIdx > descriptionIdx { + // the line before the first command must be blank. + if p.lines[firstCommandIdx-1].kind != kindBlank { + addError(r, "TLDR007", section.commands[0].lineNumber) + } + } + } + } +} + +// checkExampleDescriptionStartsWithCapital enforces TLDR015. +// +// It reports an error if an example description starts with a lower-case letter. +// A leading '[' (a placeholder) is allowed. +func checkExampleDescriptionStartsWithCapital(p *parsedPage, r *Result) { + for _, section := range p.exampleSections { + val := strings.TrimSpace(section.description) + if val == "" { + continue + } + + // allowed: uppercase letter, or '['. + runes := []rune(val) + if runes[0] == '[' { + continue + } + + if unicode.IsLetter(runes[0]) && unicode.IsLower(runes[0]) { + addError(r, "TLDR015", section.descriptionLineNumber) + } + } +} + +// checkMaximumExampleCount enforces TLDR019. +// +// It reports an error if the page contains more than 8 examples. +func checkMaximumExampleCount(p *parsedPage, r *Result) { + if len(p.exampleSections) > 8 { + addError(r, "TLDR019", 0) + } +} + +// checkInfinitiveTense enforces TLDR104. +// +// It reports an error if an example description uses the gerund or +// present tense instead of the infinitive tense. +func checkInfinitiveTense(p *parsedPage, r *Result) { + for _, section := range p.exampleSections { + if infinitiveTensePattern.MatchString(section.description) { + addError(r, "TLDR104", section.descriptionLineNumber) + } + } +} + +// checkSingleCommandPerExample enforces TLDR105. +// +// It reports an error for every command in an example that has more than one command. +func checkSingleCommandPerExample(p *parsedPage, r *Result) { + for _, section := range p.exampleSections { + if len(section.commands) > 1 { + for _, cmd := range section.commands { + addError(r, "TLDR105", cmd.lineNumber) + } + } + } +} + +// lineIndex returns the index of the line +// with the given line number. +// It returns -1 if no such line exists. +func lineIndex(lines []parsedLine, lineNumber int) int { + for i, l := range lines { + if l.lineNumber == lineNumber { + return i + } + } + + return -1 +} diff --git a/internal/lint/example_rules_test.go b/internal/lint/example_rules_test.go new file mode 100644 index 0000000..6d5a780 --- /dev/null +++ b/internal/lint/example_rules_test.go @@ -0,0 +1,411 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckExampleDescriptionEndsWithColon(t *testing.T) { + withColon := commandSection{ + description: "List all files:", + descriptionLineNumber: 1, + } + withoutColon := commandSection{ + description: "List all files", + descriptionLineNumber: 2, + } + empty := commandSection{ + description: "", + descriptionLineNumber: 3, + } + + tests := []struct { + name string + exampleSections []commandSection + wantCodes []string + }{ + {name: "ends with colon passes", exampleSections: []commandSection{withColon}, wantCodes: nil}, + {name: "missing colon fails", exampleSections: []commandSection{withoutColon}, wantCodes: []string{"TLDR005"}}, + {name: "empty description passes", exampleSections: []commandSection{empty}, wantCodes: nil}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkExampleDescriptionEndsWithColon( + &parsedPage{ + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckExampleDescriptionSurroundedByBlankLines(t *testing.T) { + blank := parsedLine{ + kind: kindBlank, + lineNumber: 0, + rawLine: "", + } + title := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: "# App", + content: "App", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Description.", + content: "Description.", + } + exampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 3, + rawLine: "- List all files:", + content: "List all files:", + } + exampleDescriptionNoBlankBefore := parsedLine{ + kind: kindExampleDesc, + lineNumber: 5, + rawLine: "- List all files:", + content: "List all files:", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 4, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + commandNoBlankBefore := parsedLine{ + kind: kindCommand, + lineNumber: 6, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + commandWithBlankBefore := parsedLine{ + kind: kindCommand, + lineNumber: 7, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + + tests := []struct { + name string + lines []parsedLine + exampleSections []commandSection + wantCodes []string + }{ + { + name: "surrounded by blank lines passes", + lines: []parsedLine{blank, exampleDescription, blank, command}, + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 3, + commands: []parsedLine{command}, + }, + }, + wantCodes: nil, + }, + { + name: "no blank before description fails", + lines: []parsedLine{ + title, + description, + exampleDescriptionNoBlankBefore, + blank, + commandWithBlankBefore, + }, + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 5, + commands: []parsedLine{commandWithBlankBefore}, + }, + }, + wantCodes: []string{"TLDR007"}, + }, + { + name: "no blank before command fails", + lines: []parsedLine{ + blank, + exampleDescription, + commandNoBlankBefore, + }, + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 3, + commands: []parsedLine{commandNoBlankBefore}, + }, + }, + wantCodes: []string{"TLDR007"}, + }, + { + name: "description without command passes", + lines: []parsedLine{blank, exampleDescription}, + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 3, + }, + }, + wantCodes: nil, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkExampleDescriptionSurroundedByBlankLines( + &parsedPage{ + lines: tt.lines, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckExampleDescriptionStartsWithCapital(t *testing.T) { + capital := commandSection{ + description: "List all files:", + descriptionLineNumber: 1, + } + lowercase := commandSection{ + description: "list all files:", + descriptionLineNumber: 2, + } + placeholder := commandSection{ + description: "[file] to copy:", + descriptionLineNumber: 3, + } + empty := commandSection{ + description: "", + descriptionLineNumber: 4, + } + + tests := []struct { + name string + exampleSections []commandSection + wantCodes []string + }{ + {name: "capital start passes", exampleSections: []commandSection{capital}, wantCodes: nil}, + {name: "lowercase start fails", exampleSections: []commandSection{lowercase}, wantCodes: []string{"TLDR015"}}, + {name: "placeholder start passes", exampleSections: []commandSection{placeholder}, wantCodes: nil}, + {name: "empty description passes", exampleSections: []commandSection{empty}, wantCodes: nil}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkExampleDescriptionStartsWithCapital( + &parsedPage{ + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckMaximumExampleCount(t *testing.T) { + section := commandSection{ + description: "List all files:", + descriptionLineNumber: 1, + } + + tests := []struct { + name string + exampleSections []commandSection + wantCode string + }{ + {name: "eight examples pass", exampleSections: makeExampleSections(section, 8), wantCode: ""}, + {name: "nine examples fail", exampleSections: makeExampleSections(section, 9), wantCode: "TLDR019"}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkMaximumExampleCount(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }, + ) + } +} + +func TestCheckInfinitiveTense(t *testing.T) { + infinitive := commandSection{ + description: "List all files:", + descriptionLineNumber: 1, + } + present := commandSection{ + description: "Writes files:", + descriptionLineNumber: 2, + } + gerund := commandSection{ + description: "Writing files:", + descriptionLineNumber: 3, + } + + tests := []struct { + name string + exampleSections []commandSection + wantCodes []string + }{ + {name: "infinitive tense passes", exampleSections: []commandSection{infinitive}, wantCodes: nil}, + {name: "present tense fails", exampleSections: []commandSection{present}, wantCodes: []string{"TLDR104"}}, + {name: "gerund fails", exampleSections: []commandSection{gerund}, wantCodes: []string{"TLDR104"}}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkInfinitiveTense( + &parsedPage{ + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckSingleCommandPerExample(t *testing.T) { + command_1 := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + command_2 := parsedLine{ + kind: kindCommand, + lineNumber: 3, + rawLine: "`ls -la`", + content: "ls -la", + hasClosingBacktick: true, + } + + tests := []struct { + name string + exampleSections []commandSection + wantCodes []string + }{ + { + name: "single command passes", + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 1, + commands: []parsedLine{command_1}, + }, + }, + }, + { + name: "two commands fail on both", + exampleSections: []commandSection{ + { + description: "List all files:", + descriptionLineNumber: 1, + commands: []parsedLine{ + command_1, + command_2, + }, + }, + }, + wantCodes: []string{"TLDR105", "TLDR105"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkSingleCommandPerExample( + &parsedPage{exampleSections: tt.exampleSections}, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestLineIndex(t *testing.T) { + tests := []struct { + name string + lines []parsedLine + lineNumber int + want int + }{ + { + name: "line not found returns minus one", + lines: []parsedLine{{lineNumber: 0}, {lineNumber: 1}}, + lineNumber: 5, + want: -1, + }, + { + name: "empty lines returns minus one", + lines: nil, + lineNumber: 0, + want: -1, + }, + { + name: "line is first", + lines: []parsedLine{{lineNumber: 2}, {lineNumber: 4}}, + lineNumber: 2, + want: 0, + }, + { + name: "line is in the middle", + lines: []parsedLine{{lineNumber: 0}, {lineNumber: 3}, {lineNumber: 9}}, + lineNumber: 3, + want: 1, + }, + { + name: "duplicate line numbers returns first", + lines: []parsedLine{{lineNumber: 5}, {lineNumber: 7}, {lineNumber: 5}}, + lineNumber: 5, + want: 0, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := lineIndex(tt.lines, tt.lineNumber) + require.Equal(t, tt.want, got) + }, + ) + } +} + +// makeExampleSections returns a slice containing n copies of section. +func makeExampleSections(section commandSection, n int) []commandSection { + sections := make([]commandSection, n) + for i := range sections { + sections[i] = section + } + return sections +} From bf2fd44ecdb1d6859c8276956f8c9812195f70b3 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 7 Aug 2026 22:49:24 +0530 Subject: [PATCH 34/58] lint: command rules --- internal/lint/command_rules.go | 108 +++++++++++ internal/lint/command_rules_test.go | 280 ++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 internal/lint/command_rules.go create mode 100644 internal/lint/command_rules_test.go diff --git a/internal/lint/command_rules.go b/internal/lint/command_rules.go new file mode 100644 index 0000000..2098f2d --- /dev/null +++ b/internal/lint/command_rules.go @@ -0,0 +1,108 @@ +package lint + +import "strings" + +// checkCommandWhitespace enforces TLDR021. +// +// It reports an error if a command example begins +// or ends with whitespace. +// +// An escaped space (backslash followed by a space) is allowed. +func checkCommandWhitespace(p *parsedPage, r *Result) { + for _, l := range p.lines { + if l.kind != kindCommand { + continue + } + + // leading space (but allow escaped space \ ). + if len(l.content) > 0 && + l.content[0] == ' ' && + !strings.HasPrefix(l.content, `\ `) { + addError(r, "TLDR021", l.lineNumber) + continue + } + + // trailing space (but allow escaped space \ ). + if len(l.content) > 0 && + l.content[len(l.content)-1] == ' ' && + !strings.HasSuffix(l.content, `\ `) { + addError(r, "TLDR021", l.lineNumber) + } + } +} + +// checkCommandDescriptionAnnotated enforces TLDR101. +// +// It reports an error for text that appears between the title +// and the first example description but is not annotated with a '> ' prefix. +func checkCommandDescriptionAnnotated(p *parsedPage, r *Result) { + for i, l := range p.lines { + if l.kind != kindText { + continue + } + + for j := i + 1; j < len(p.lines); j++ { + if p.lines[j].kind == kindBlank || p.lines[j].kind == kindText { + continue + } + if p.lines[j].kind == kindExampleDesc { + addError(r, "TLDR101", l.lineNumber) + } + break + } + } +} + +// checkExampleDescriptionAnnotated enforces TLDR102. +// +// It reports an error when: +// - unannotated text appears after an example description +// - unannotated text is followed by a command, +// indicating that the text is likely an example description +// missing the "- " prefix. +func checkExampleDescriptionAnnotated(p *parsedPage, r *Result) { + for i, l := range p.lines { + if l.kind != kindText { + continue + } + + if i > 0 && p.lines[i-1].kind == kindExampleDesc { + addError(r, "TLDR102", l.lineNumber) + continue + } + + // text before any example description but followed by a command -> TLDR102 + for j := i + 1; j < len(p.lines); j++ { + if p.lines[j].kind == kindBlank { + continue + } + if p.lines[j].kind == kindCommand { + addError(r, "TLDR102", l.lineNumber) + } + break + } + } +} + +// checkCommandClosingBacktick enforces TLDR103. +// +// It reports an error if a command example is missing its closing backtick. +func checkCommandClosingBacktick(p *parsedPage, r *Result) { + for _, l := range p.lines { + if l.kind == kindCommand && !l.hasClosingBacktick { + addError(r, "TLDR103", l.lineNumber) + } + } +} + +// checkCommandNotEmpty enforces TLDR110. +// +// It reports an error if a command example is empty, +// that is, contains nothing between the backticks. +func checkCommandNotEmpty(p *parsedPage, r *Result) { + for _, l := range p.lines { + if l.kind == kindCommand && l.hasClosingBacktick && l.content == "" { + addError(r, "TLDR110", l.lineNumber) + } + } +} diff --git a/internal/lint/command_rules_test.go b/internal/lint/command_rules_test.go new file mode 100644 index 0000000..fce8ab3 --- /dev/null +++ b/internal/lint/command_rules_test.go @@ -0,0 +1,280 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckCommandWhitespace(t *testing.T) { + clean := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + leadingSpace := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "` ls`", + content: " ls", + hasClosingBacktick: true, + } + trailingSpace := parsedLine{ + kind: kindCommand, + lineNumber: 3, + rawLine: "`ls `", + content: "ls ", + hasClosingBacktick: true, + } + escapedLeadingSpace := parsedLine{ + kind: kindCommand, + lineNumber: 4, + rawLine: "`\\ ls`", + content: `\ ls`, + hasClosingBacktick: true, + } + escapedTrailingSpace := parsedLine{ + kind: kindCommand, + lineNumber: 5, + rawLine: "`ls\\ `", + content: `ls\ `, + hasClosingBacktick: true, + } + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + {name: "clean command passes", lines: []parsedLine{clean}, wantCodes: nil}, + {name: "leading space fails", lines: []parsedLine{leadingSpace}, wantCodes: []string{"TLDR021"}}, + {name: "trailing space fails", lines: []parsedLine{trailingSpace}, wantCodes: []string{"TLDR021"}}, + {name: "escaped leading space passes", lines: []parsedLine{escapedLeadingSpace}, wantCodes: nil}, + {name: "escaped trailing space passes", lines: []parsedLine{escapedTrailingSpace}, wantCodes: nil}, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkCommandWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckCommandDescriptionAnnotated(t *testing.T) { + text := parsedLine{ + kind: kindText, + lineNumber: 1, + rawLine: "unannotated", + content: "unannotated", + } + exampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 2, + rawLine: "- Example:", + content: "Example:", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: "> Description.", + content: "Description.", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 4, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "no stray text passes", + lines: []parsedLine{description, exampleDescription, command}, + wantCodes: nil, + }, + { + name: "text before example description fails", + lines: []parsedLine{text, exampleDescription, command}, + wantCodes: []string{"TLDR101"}, + }, + { + name: "text before description passes", + lines: []parsedLine{text, description}, + wantCodes: nil, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkCommandDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckExampleDescriptionAnnotated(t *testing.T) { + text := parsedLine{ + kind: kindText, + lineNumber: 1, + rawLine: "unannotated", + content: "unannotated", + } + exampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 2, + rawLine: "- Example:", + content: "Example:", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 3, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 4, + rawLine: "> Description.", + content: "Description.", + } + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "no stray text passes", + lines: []parsedLine{exampleDescription, command}, + wantCodes: nil, + }, + { + name: "text after example description fails", + lines: []parsedLine{exampleDescription, text, command}, + wantCodes: []string{"TLDR102"}, + }, + { + name: "text followed by command fails", + lines: []parsedLine{text, command}, + wantCodes: []string{"TLDR102"}, + }, + { + name: "text followed by description passes", + lines: []parsedLine{text, description}, + wantCodes: nil, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkExampleDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckCommandClosingBacktick(t *testing.T) { + closed := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + unclosed := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "`ls", + content: "ls", + hasClosingBacktick: false, + } + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "closed command passes", + lines: []parsedLine{closed}, + wantCodes: nil, + }, + { + name: "unclosed command fails", + lines: []parsedLine{unclosed}, + wantCodes: []string{"TLDR103"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkCommandClosingBacktick(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} + +func TestCheckCommandNotEmpty(t *testing.T) { + nonEmpty := parsedLine{ + kind: kindCommand, + lineNumber: 1, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } + empty := parsedLine{ + kind: kindCommand, + lineNumber: 2, + rawLine: "``", + content: "", + hasClosingBacktick: true, + } + + tests := []struct { + name string + lines []parsedLine + wantCodes []string + }{ + { + name: "non-empty command passes", + lines: []parsedLine{nonEmpty}, + wantCodes: nil, + }, + { + name: "empty command fails", + lines: []parsedLine{empty}, + wantCodes: []string{"TLDR110"}, + }, + } + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + r := &Result{} + checkCommandNotEmpty(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }, + ) + } +} From 0e2b2b7a7d1b125cf6e380d055a87efde6af7c19 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 7 Aug 2026 23:27:38 +0530 Subject: [PATCH 35/58] lint: global Lint --- internal/lint/lint.go | 109 ++++++++++++++++++++++++++++++++++++- internal/lint/lint_test.go | 53 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 internal/lint/lint_test.go diff --git a/internal/lint/lint.go b/internal/lint/lint.go index 222a326..ef73c5c 100644 --- a/internal/lint/lint.go +++ b/internal/lint/lint.go @@ -1,6 +1,11 @@ package lint -import "fmt" +import ( + "fmt" + "io" + "os" + "path/filepath" +) // Error represents a single lint violation. type Error struct { @@ -51,6 +56,106 @@ var ErrorCodes = map[string]string{ "TLDR112": "Terms `stdin`, `stdout`, `stderr`, and `regex` should be lowercase and wrapped in backticks", } +// rule couples a TLDR code with the check that reports it. +type rule struct { + code string + check func(*parsedPage, *Result) +} + +// contentRules lists every rule that runs on page content, in ascending +// TLDR code order. Filename-only rules (TLDR107, TLDR108, TLDR109, TLDR111) +// are excluded because they require a filename. +var contentRules = []rule{ + {"TLDR001", checkLeadingWhitespace}, + {"TLDR002", checkSpaceAfterPrefix}, + {"TLDR003", checkDescriptionStartsWithCapital}, + {"TLDR004", checkDescriptionEndsWithPeriod}, + {"TLDR005", checkExampleDescriptionEndsWithColon}, + {"TLDR006", checkTitleDescriptionSeparator}, + {"TLDR007", checkExampleDescriptionSurroundedByBlankLines}, + {"TLDR008", checkNoTrailingWhitespaceAtEOF}, + {"TLDR009", checkEndsWithNewline}, + {"TLDR010", checkUnixLineEndings}, + {"TLDR011", checkConsecutiveBlankLines}, + {"TLDR012", checkNoTabs}, + {"TLDR013", checkTitleCharacters}, + {"TLDR014", checkTrailingWhitespace}, + {"TLDR015", checkExampleDescriptionStartsWithCapital}, + {"TLDR016", checkInformationLinkLabel}, + {"TLDR017", checkInformationLinkBrackets}, + {"TLDR018", checkSingleInformationLink}, + {"TLDR019", checkMaximumExampleCount}, + {"TLDR020", checkNoteLabelFormat}, + {"TLDR021", checkCommandWhitespace}, + {"TLDR101", checkCommandDescriptionAnnotated}, + {"TLDR102", checkExampleDescriptionAnnotated}, + {"TLDR103", checkCommandClosingBacktick}, + {"TLDR104", checkInfinitiveTense}, + {"TLDR105", checkSingleCommandPerExample}, + {"TLDR106", checkTitleHash}, + {"TLDR110", checkCommandNotEmpty}, + {"TLDR112", checkStandardTermsInBackticks}, +} + +// filenameRule couples a TLDR code with the check that reports it. +type filenameRule struct { + code string + check func(string, *Result) +} + +// filenameRules lists every rule that runs on a page's filename, in +// ascending TLDR code order. +var filenameRules = []filenameRule{ + {"TLDR107", checkFileExtension}, + {"TLDR108", checkFilenameWhitespace}, + {"TLDR109", checkFilenameLowercase}, + {"TLDR111", checkForbiddenFilenameCharacters}, +} + +// Lint runs all applicable rules on the page read from f +// and returns the violations found. +// Rules may inspect the file's content or name. +// Any rule code listed in ignore is skipped. +func Lint( + f *os.File, + ignore ...string, +) (*Result, error) { + r := &Result{} + + if _, err := f.Seek(0, io.SeekStart); err != nil { + return r, err + } + + content, err := io.ReadAll(f) + if err != nil { + return r, err + } + + skipped := make(map[string]struct{}, len(ignore)) + for _, code := range ignore { + skipped[code] = struct{}{} + } + + if page := parse(string(content)); page != nil { + for _, rl := range contentRules { + if _, ok := skipped[rl.code]; ok { + continue + } + rl.check(page, r) + } + } + + name := filepath.Base(f.Name()) + for _, rl := range filenameRules { + if _, ok := skipped[rl.code]; ok { + continue + } + rl.check(name, r) + } + + return r, nil +} + // addError is a convenience helper used by rules. func addError(r *Result, code string, line int) { desc := ErrorCodes[code] @@ -67,6 +172,8 @@ func addError(r *Result, code string, line int) { ) } +// String returns the error as a formatted string +// containing its code, line number, and description. func (e Error) String() string { return fmt.Sprintf( "%s:%d %s", diff --git a/internal/lint/lint_test.go b/internal/lint/lint_test.go new file mode 100644 index 0000000..0348a38 --- /dev/null +++ b/internal/lint/lint_test.go @@ -0,0 +1,53 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLint(t *testing.T) { + clean := "# App\n\n> Description.\n\n- List files:\n\n`ls`\n" + missingPeriod := "# App\n\n> Description\n\n- List files:\n\n`ls`\n" + + tests := []struct { + name string + content string + badName bool + ignore []string + wantCode []string + line int + }{ + {"clean page reports nothing", clean, false, nil, nil, 0}, + {"missing period", missingPeriod, false, nil, []string{"TLDR004"}, 2}, + {"ignored rule skipped", missingPeriod, false, []string{"TLDR004"}, nil, 0}, + {"filename rules run on bad name", clean, true, nil, []string{"TLDR107", "TLDR108", "TLDR109"}, 0}, + {"bad ignore code is harmless", clean, false, []string{"TLDR999"}, nil, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name := "app.md" + if tt.badName { + name = "Bad File.txt" + } + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) + + f, err := os.Open(path) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + + r, err := Lint(f, tt.ignore...) + require.NoError(t, err) + require.Equal(t, tt.wantCode, errorCodes(r)) + if tt.line != 0 { + require.Equal(t, tt.line, r.Errors[0].Line) + } + }) + } +} From aa1f2644f89506280840e4a6dbc797d71f4d251e Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 7 Aug 2026 23:28:06 +0530 Subject: [PATCH 36/58] lint: Remove unused field --- internal/lint/parse.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/lint/parse.go b/internal/lint/parse.go index 1eea528..51ac644 100644 --- a/internal/lint/parse.go +++ b/internal/lint/parse.go @@ -62,9 +62,6 @@ type parsedPage struct { // description lines that are "More information: ..." links infoLinks []parsedLine - // note lines (unused for now; kept for future rules) - notes []parsedLine - // example descriptions paired with their commands exampleSections []commandSection } From 78d53e032f5d49562b39ca7907c48b14fa56f5b7 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 8 Aug 2026 11:33:34 +0530 Subject: [PATCH 37/58] fix(lint): align whitespace rules with reference behavior - simplify leading whitespace check - normalize tabs when checking prefix spacing - report consecutive blank lines once per run - report TLDR012 with the offending line number --- internal/lint/file_rules.go | 42 ++++++++++++++++++++------------ internal/lint/file_rules_test.go | 11 ++++++--- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/internal/lint/file_rules.go b/internal/lint/file_rules.go index de69dc5..fa4cd6a 100644 --- a/internal/lint/file_rules.go +++ b/internal/lint/file_rules.go @@ -13,7 +13,8 @@ func checkLeadingWhitespace(p *parsedPage, r *Result) { continue } - // first non-blank line: check for leading space/tab on the line itself. + // first non-blank line: + // check for leading space/tab on the line itself. if len(l.rawLine) > 0 && (l.rawLine[0] == ' ' || l.rawLine[0] == '\t') { addError(r, "TLDR001", l.lineNumber) @@ -30,13 +31,17 @@ func checkLeadingWhitespace(p *parsedPage, r *Result) { // // It reports an error if a title, description, or example description // line does not have exactly one space -// after its marker ('#', '>', '-'). +// after its marker ('#', '>', '-'), with tabs normalized to spaces +// (the reference re-lexes tab lines with tabs replaced). func checkSpaceAfterPrefix(p *parsedPage, r *Result) { for _, l := range p.lines { switch l.kind { case kindTitle, kindDescription, kindExampleDesc: - if len(l.rawLine) > 1 && l.rawLine[1] != ' ' { - addError(r, "TLDR002", l.lineNumber) + if len(l.rawLine) > 1 { + after := strings.ReplaceAll(l.rawLine[1:2], "\t", " ") + if after != " " { + addError(r, "TLDR002", l.lineNumber) + } } } } @@ -90,28 +95,33 @@ func checkUnixLineEndings(p *parsedPage, r *Result) { // checkConsecutiveBlankLines enforces TLDR011. // -// It reports an error for every blank line that directly -// follows another blank line. +// It reports an error for every blank line +// run longer than one, per run. +// A trailing run at EOF is consumed by TLDR008's +// whitespace-at-end-of-file rule and is not reported here. func checkConsecutiveBlankLines(p *parsedPage, r *Result) { - count := 0 + run := 0 for _, l := range p.lines { if l.kind == kindBlank { - count++ - if count > 1 { - addError(r, "TLDR011", l.lineNumber) - } - } else { - count = 0 + run++ + continue + } + if run > 1 { + addError(r, "TLDR011", l.lineNumber) } + run = 0 } } // checkNoTabs enforces TLDR012. // -// It reports an error if the page contains any tab character. +// It reports an error for every line +// that contains a tab character. func checkNoTabs(p *parsedPage, r *Result) { - if strings.Contains(p.rawContent, "\t") { - addError(r, "TLDR012", 0) + for _, l := range p.lines { + if strings.Contains(l.rawLine, "\t") { + addError(r, "TLDR012", l.lineNumber) + } } } diff --git a/internal/lint/file_rules_test.go b/internal/lint/file_rules_test.go index 290e07a..0a0ef0c 100644 --- a/internal/lint/file_rules_test.go +++ b/internal/lint/file_rules_test.go @@ -191,9 +191,14 @@ func TestCheckConsecutiveBlankLines(t *testing.T) { wantCodes: []string{"TLDR011"}, }, { - name: "three consecutive blank lines fail twice", + name: "three consecutive blank lines fail once", lines: []parsedLine{title, blank1, blank2, blank3, description}, - wantCodes: []string{"TLDR011", "TLDR011"}, + wantCodes: []string{"TLDR011"}, + }, + { + name: "page ending in blank run passes", + lines: []parsedLine{title, blank1, blank2, blank3}, + wantCodes: nil, }, } for _, tt := range tests { @@ -222,7 +227,7 @@ func TestCheckNoTabs(t *testing.T) { tt.name, func(t *testing.T) { r := &Result{} - checkNoTabs(&parsedPage{rawContent: tt.raw}, r) + checkNoTabs(&parsedPage{rawContent: tt.raw, lines: parseLines(tt.raw)}, r) require.Equal(t, tt.wantCode, errorCode(r)) }, ) From d6a279255e49e502c230fb31c78c31fd63845244 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 8 Aug 2026 11:36:39 +0530 Subject: [PATCH 38/58] fix(lint): report TLDR105 only for extra commands --- internal/lint/example_rules.go | 10 +++++----- internal/lint/example_rules_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/lint/example_rules.go b/internal/lint/example_rules.go index 1d03e50..1773847 100644 --- a/internal/lint/example_rules.go +++ b/internal/lint/example_rules.go @@ -102,15 +102,15 @@ func checkInfinitiveTense(p *parsedPage, r *Result) { } } +// checkSingleCommandPerExample enforces TLDR105. // checkSingleCommandPerExample enforces TLDR105. // -// It reports an error for every command in an example that has more than one command. +// It reports an error for every command after the first +// in an example with multiple commands. func checkSingleCommandPerExample(p *parsedPage, r *Result) { for _, section := range p.exampleSections { - if len(section.commands) > 1 { - for _, cmd := range section.commands { - addError(r, "TLDR105", cmd.lineNumber) - } + for i := 1; i < len(section.commands); i++ { + addError(r, "TLDR105", section.commands[i].lineNumber) } } } diff --git a/internal/lint/example_rules_test.go b/internal/lint/example_rules_test.go index 6d5a780..658af73 100644 --- a/internal/lint/example_rules_test.go +++ b/internal/lint/example_rules_test.go @@ -323,7 +323,7 @@ func TestCheckSingleCommandPerExample(t *testing.T) { }, }, { - name: "two commands fail on both", + name: "two commands fail on the second", exampleSections: []commandSection{ { description: "List all files:", @@ -334,7 +334,7 @@ func TestCheckSingleCommandPerExample(t *testing.T) { }, }, }, - wantCodes: []string{"TLDR105", "TLDR105"}, + wantCodes: []string{"TLDR105"}, }, } for _, tt := range tests { From 59212fc0cd3cf98177a8005740845b8484c15c93 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 8 Aug 2026 11:46:59 +0530 Subject: [PATCH 39/58] lint: Add specifications --- .gitattributes | 3 + internal/lint/example_rules.go | 1 - internal/lint/lint_test.go | 184 ++++++++++++++---- internal/lint/specs/pages/failing/001.md | 4 + internal/lint/specs/pages/failing/002.md | 7 + internal/lint/specs/pages/failing/003.md | 7 + internal/lint/specs/pages/failing/004.md | 6 + internal/lint/specs/pages/failing/005.md | 11 ++ internal/lint/specs/pages/failing/006.md | 2 + internal/lint/specs/pages/failing/007.md | 5 + internal/lint/specs/pages/failing/008.md | 11 ++ internal/lint/specs/pages/failing/009.md | 7 + internal/lint/specs/pages/failing/010.md | 7 + internal/lint/specs/pages/failing/011.md | 9 + internal/lint/specs/pages/failing/012.md | 3 + internal/lint/specs/pages/failing/013.md | 3 + internal/lint/specs/pages/failing/014.md | 12 ++ internal/lint/specs/pages/failing/015.md | 7 + internal/lint/specs/pages/failing/016.md | 8 + internal/lint/specs/pages/failing/017.md | 8 + internal/lint/specs/pages/failing/018.md | 10 + internal/lint/specs/pages/failing/019.md | 40 ++++ internal/lint/specs/pages/failing/020.md | 9 + internal/lint/specs/pages/failing/021.md | 16 ++ internal/lint/specs/pages/failing/101.md | 7 + internal/lint/specs/pages/failing/102.md | 7 + internal/lint/specs/pages/failing/103.md | 11 ++ internal/lint/specs/pages/failing/104.md | 23 +++ internal/lint/specs/pages/failing/105.md | 9 + internal/lint/specs/pages/failing/106.md | 7 + internal/lint/specs/pages/failing/107 | 7 + internal/lint/specs/pages/failing/108 .md | 7 + internal/lint/specs/pages/failing/109A.md | 7 + internal/lint/specs/pages/failing/110.md | 9 + internal/lint/specs/pages/failing/111.md | 7 + internal/lint/specs/pages/failing/112.md | 29 +++ internal/lint/specs/pages/passing/!.md | 36 ++++ internal/lint/specs/pages/passing/$.md | 32 +++ internal/lint/specs/pages/passing/%.md | 28 +++ internal/lint/specs/pages/passing/((.md | 7 + internal/lint/specs/pages/passing/+.md | 21 ++ internal/lint/specs/pages/passing/,.md | 16 ++ internal/lint/specs/pages/passing/..md | 7 + internal/lint/specs/pages/passing/[.md | 33 ++++ internal/lint/specs/pages/passing/[[.md | 37 ++++ internal/lint/specs/pages/passing/].md | 7 + internal/lint/specs/pages/passing/]].md | 7 + internal/lint/specs/pages/passing/^.md | 21 ++ internal/lint/specs/pages/passing/bracket.md | 9 + internal/lint/specs/pages/passing/colon.md | 12 ++ .../lint/specs/pages/passing/descriptions.md | 5 + .../lint/specs/pages/passing/greater-than.md | 28 +++ .../lint/specs/pages/passing/less-than.md | 32 +++ .../lint/specs/pages/passing/lower-case.md | 4 + .../lint/specs/pages/passing/question-mark.md | 16 ++ .../specs/pages/passing/special-characters.md | 9 + .../specs/pages/passing/standardized-terms.md | 25 +++ .../lint/specs/pages/passing/vertical-bar.md | 12 ++ internal/lint/specs/pages/passing/{.md | 36 ++++ internal/lint/specs/pages/passing/}.md | 7 + internal/lint/specs/pages/passing/~.md | 16 ++ 61 files changed, 933 insertions(+), 40 deletions(-) create mode 100644 internal/lint/specs/pages/failing/001.md create mode 100644 internal/lint/specs/pages/failing/002.md create mode 100644 internal/lint/specs/pages/failing/003.md create mode 100644 internal/lint/specs/pages/failing/004.md create mode 100644 internal/lint/specs/pages/failing/005.md create mode 100644 internal/lint/specs/pages/failing/006.md create mode 100644 internal/lint/specs/pages/failing/007.md create mode 100644 internal/lint/specs/pages/failing/008.md create mode 100644 internal/lint/specs/pages/failing/009.md create mode 100644 internal/lint/specs/pages/failing/010.md create mode 100644 internal/lint/specs/pages/failing/011.md create mode 100644 internal/lint/specs/pages/failing/012.md create mode 100644 internal/lint/specs/pages/failing/013.md create mode 100644 internal/lint/specs/pages/failing/014.md create mode 100644 internal/lint/specs/pages/failing/015.md create mode 100644 internal/lint/specs/pages/failing/016.md create mode 100644 internal/lint/specs/pages/failing/017.md create mode 100644 internal/lint/specs/pages/failing/018.md create mode 100644 internal/lint/specs/pages/failing/019.md create mode 100644 internal/lint/specs/pages/failing/020.md create mode 100644 internal/lint/specs/pages/failing/021.md create mode 100644 internal/lint/specs/pages/failing/101.md create mode 100644 internal/lint/specs/pages/failing/102.md create mode 100644 internal/lint/specs/pages/failing/103.md create mode 100644 internal/lint/specs/pages/failing/104.md create mode 100644 internal/lint/specs/pages/failing/105.md create mode 100644 internal/lint/specs/pages/failing/106.md create mode 100644 internal/lint/specs/pages/failing/107 create mode 100644 internal/lint/specs/pages/failing/108 .md create mode 100644 internal/lint/specs/pages/failing/109A.md create mode 100644 internal/lint/specs/pages/failing/110.md create mode 100644 internal/lint/specs/pages/failing/111.md create mode 100644 internal/lint/specs/pages/failing/112.md create mode 100644 internal/lint/specs/pages/passing/!.md create mode 100644 internal/lint/specs/pages/passing/$.md create mode 100644 internal/lint/specs/pages/passing/%.md create mode 100644 internal/lint/specs/pages/passing/((.md create mode 100644 internal/lint/specs/pages/passing/+.md create mode 100644 internal/lint/specs/pages/passing/,.md create mode 100644 internal/lint/specs/pages/passing/..md create mode 100644 internal/lint/specs/pages/passing/[.md create mode 100644 internal/lint/specs/pages/passing/[[.md create mode 100644 internal/lint/specs/pages/passing/].md create mode 100644 internal/lint/specs/pages/passing/]].md create mode 100644 internal/lint/specs/pages/passing/^.md create mode 100644 internal/lint/specs/pages/passing/bracket.md create mode 100644 internal/lint/specs/pages/passing/colon.md create mode 100644 internal/lint/specs/pages/passing/descriptions.md create mode 100644 internal/lint/specs/pages/passing/greater-than.md create mode 100644 internal/lint/specs/pages/passing/less-than.md create mode 100644 internal/lint/specs/pages/passing/lower-case.md create mode 100644 internal/lint/specs/pages/passing/question-mark.md create mode 100644 internal/lint/specs/pages/passing/special-characters.md create mode 100644 internal/lint/specs/pages/passing/standardized-terms.md create mode 100644 internal/lint/specs/pages/passing/vertical-bar.md create mode 100644 internal/lint/specs/pages/passing/{.md create mode 100644 internal/lint/specs/pages/passing/}.md create mode 100644 internal/lint/specs/pages/passing/~.md diff --git a/.gitattributes b/.gitattributes index 6313b56..5ea95f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ * text=auto eol=lf + +# Required for TLDR010 (Only Unix-style line endings allowed) +internal/lint/specs/pages/failing/010.md binary diff --git a/internal/lint/example_rules.go b/internal/lint/example_rules.go index 1773847..c693560 100644 --- a/internal/lint/example_rules.go +++ b/internal/lint/example_rules.go @@ -102,7 +102,6 @@ func checkInfinitiveTense(p *parsedPage, r *Result) { } } -// checkSingleCommandPerExample enforces TLDR105. // checkSingleCommandPerExample enforces TLDR105. // // It reports an error for every command after the first diff --git a/internal/lint/lint_test.go b/internal/lint/lint_test.go index 0348a38..a60c87f 100644 --- a/internal/lint/lint_test.go +++ b/internal/lint/lint_test.go @@ -8,46 +8,152 @@ import ( "github.com/stretchr/testify/require" ) -func TestLint(t *testing.T) { - clean := "# App\n\n> Description.\n\n- List files:\n\n`ls`\n" - missingPeriod := "# App\n\n> Description\n\n- List files:\n\n`ls`\n" - - tests := []struct { - name string - content string - badName bool - ignore []string - wantCode []string - line int - }{ - {"clean page reports nothing", clean, false, nil, nil, 0}, - {"missing period", missingPeriod, false, nil, []string{"TLDR004"}, 2}, - {"ignored rule skipped", missingPeriod, false, []string{"TLDR004"}, nil, 0}, - {"filename rules run on bad name", clean, true, nil, []string{"TLDR107", "TLDR108", "TLDR109"}, 0}, - {"bad ignore code is harmless", clean, false, []string{"TLDR999"}, nil, 0}, +// specFixture couples a failing spec page with the errors it must produce, +// mirroring the reference tldr-lint's tldr-lint.spec.js. +type specFixture struct { + name string + want []string + count int + subset bool +} + +func TestLintSpecsFailing(t *testing.T) { + tests := []specFixture{ + {"failing/001.md", []string{"TLDR001"}, 1, false}, + {"failing/002.md", []string{"TLDR002"}, 3, false}, + {"failing/003.md", []string{"TLDR003"}, 1, false}, + {"failing/004.md", []string{"TLDR004", "TLDR014"}, 4, true}, + {"failing/005.md", []string{"TLDR005"}, 2, false}, + {"failing/006.md", []string{"TLDR006"}, 1, false}, + {"failing/007.md", []string{"TLDR007"}, 2, false}, + {"failing/008.md", []string{"TLDR008"}, 1, false}, + {"failing/009.md", []string{"TLDR009"}, 1, false}, + {"failing/010.md", []string{"TLDR010"}, 1, false}, + {"failing/011.md", []string{"TLDR011"}, 2, false}, + {"failing/012.md", []string{"TLDR012"}, 2, false}, + {"failing/013.md", []string{"TLDR013"}, 1, false}, + {"failing/014.md", []string{"TLDR014"}, 5, false}, + {"failing/015.md", []string{"TLDR015"}, 1, false}, + {"failing/016.md", []string{"TLDR016"}, 1, false}, + {"failing/017.md", []string{"TLDR017"}, 1, false}, + {"failing/018.md", []string{"TLDR018"}, 2, false}, + {"failing/019.md", []string{"TLDR019"}, 1, false}, + {"failing/020.md", []string{"TLDR020"}, 3, false}, + {"failing/021.md", []string{"TLDR021"}, 2, false}, + {"failing/101.md", []string{"TLDR101"}, 1, false}, + {"failing/102.md", []string{"TLDR102"}, 1, false}, + {"failing/103.md", []string{"TLDR103"}, 2, false}, + {"failing/104.md", []string{"TLDR104"}, 2, false}, + {"failing/105.md", []string{"TLDR105"}, 2, false}, + {"failing/106.md", []string{"TLDR106"}, 1, false}, + {"failing/107", []string{"TLDR107"}, 1, false}, + {"failing/108 .md", []string{"TLDR108"}, 1, false}, + {"failing/109A.md", []string{"TLDR109"}, 1, false}, + {"failing/110.md", []string{"TLDR110"}, 1, false}, + {"failing/112.md", []string{"TLDR112"}, 7, false}, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - name := "app.md" - if tt.badName { - name = "Bad File.txt" - } - - path := filepath.Join(t.TempDir(), name) - require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) - - f, err := os.Open(path) - require.NoError(t, err) - defer func() { - _ = f.Close() - }() - - r, err := Lint(f, tt.ignore...) - require.NoError(t, err) - require.Equal(t, tt.wantCode, errorCodes(r)) - if tt.line != 0 { - require.Equal(t, tt.line, r.Errors[0].Line) - } - }) + t.Run( + tt.name, + func(t *testing.T) { + f, err := os.Open(filepath.Join("specs", "pages", tt.name)) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + + r, err := Lint(f) + require.NoError(t, err) + assertSpecErrors(t, r, tt.want, tt.count, tt.subset) + }, + ) + } +} + +func TestLintSpecsForbiddenFilenameCharacters(t *testing.T) { + for _, char := range `<>:"\|?*` { + t.Run( + "111"+string(char), + func(t *testing.T) { + content, err := os.ReadFile(filepath.Join("specs", "pages", "failing", "111.md")) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "111"+string(char)+".md") + require.NoError(t, os.WriteFile(path, content, 0o600)) + + f, err := os.Open(path) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + + r, err := Lint(f) + require.NoError(t, err) + assertSpecErrors(t, r, []string{"TLDR111"}, 1, false) + }, + ) + } +} + +func TestLintSpecsPassing(t *testing.T) { + entries, err := os.ReadDir(filepath.Join("specs", "pages", "passing")) + require.NoError(t, err) + for _, entry := range entries { + t.Run( + entry.Name(), + func(t *testing.T) { + f, err := os.Open(filepath.Join("specs", "pages", "passing", entry.Name())) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + + r, err := Lint(f) + require.NoError(t, err) + assertSpecErrors(t, r, nil, 0, false) + }, + ) + } +} + +func TestLintSpecsIgnore(t *testing.T) { + f, err := os.Open(filepath.Join("specs", "pages", "failing", "004.md")) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() + + r, err := Lint(f, "TLDR014") + require.NoError(t, err) + assertSpecErrors(t, r, []string{"TLDR004"}, 2, false) +} + +// assertSpecErrors verifies that a lint result +// contains the expected number of errors and error codes. +// +// The total error count must match count exactly. +// Every code in want must be present in the result. +// When subset is false, want must also contain every distinct error code +// reported by the result; +// when true, additional error codes are allowed. +func assertSpecErrors( + t *testing.T, + r *Result, + want []string, + count int, + subset bool, +) { + t.Helper() + require.Equal(t, count, len(r.Errors)) + + seen := make(map[string]bool, len(r.Errors)) + for _, e := range r.Errors { + seen[e.Code] = true + } + for _, code := range want { + require.True(t, seen[code]) + } + if !subset { + require.Len(t, seen, len(want)) } } diff --git a/internal/lint/specs/pages/failing/001.md b/internal/lint/specs/pages/failing/001.md new file mode 100644 index 0000000..2aae446 --- /dev/null +++ b/internal/lint/specs/pages/failing/001.md @@ -0,0 +1,4 @@ + +# du + +> Estimate file space usage. diff --git a/internal/lint/specs/pages/failing/002.md b/internal/lint/specs/pages/failing/002.md new file mode 100644 index 0000000..16e403d --- /dev/null +++ b/internal/lint/specs/pages/failing/002.md @@ -0,0 +1,7 @@ +#du + +>Estimate file space usage. + +-Where dat space: + +`du` diff --git a/internal/lint/specs/pages/failing/003.md b/internal/lint/specs/pages/failing/003.md new file mode 100644 index 0000000..d10f992 --- /dev/null +++ b/internal/lint/specs/pages/failing/003.md @@ -0,0 +1,7 @@ +# du + +> estimate file space usage. + +- Where dat space: + +`du` diff --git a/internal/lint/specs/pages/failing/004.md b/internal/lint/specs/pages/failing/004.md new file mode 100644 index 0000000..fbc59bc --- /dev/null +++ b/internal/lint/specs/pages/failing/004.md @@ -0,0 +1,6 @@ +# du + +> Secretly +> This is really just +> A really big line. +> Even containing a space after period. diff --git a/internal/lint/specs/pages/failing/005.md b/internal/lint/specs/pages/failing/005.md new file mode 100644 index 0000000..3eacc64 --- /dev/null +++ b/internal/lint/specs/pages/failing/005.md @@ -0,0 +1,11 @@ +# du + +> Estimate file space usage. + +- Here goes an example ending in a period. + +`du` + +- And another, also wrong + +`du` diff --git a/internal/lint/specs/pages/failing/006.md b/internal/lint/specs/pages/failing/006.md new file mode 100644 index 0000000..1a29cb8 --- /dev/null +++ b/internal/lint/specs/pages/failing/006.md @@ -0,0 +1,2 @@ +# du +> Estimate file space usage. diff --git a/internal/lint/specs/pages/failing/007.md b/internal/lint/specs/pages/failing/007.md new file mode 100644 index 0000000..c1de16d --- /dev/null +++ b/internal/lint/specs/pages/failing/007.md @@ -0,0 +1,5 @@ +# du + +> Estimate file space usage. +- Here goes an example: +`du` diff --git a/internal/lint/specs/pages/failing/008.md b/internal/lint/specs/pages/failing/008.md new file mode 100644 index 0000000..5ea4662 --- /dev/null +++ b/internal/lint/specs/pages/failing/008.md @@ -0,0 +1,11 @@ +# du + +> Estimate file space usage. + +- Here goes an example: + +`du` + + + + diff --git a/internal/lint/specs/pages/failing/009.md b/internal/lint/specs/pages/failing/009.md new file mode 100644 index 0000000..b512634 --- /dev/null +++ b/internal/lint/specs/pages/failing/009.md @@ -0,0 +1,7 @@ +# du + +> Estimate file space usage. + +- Here goes an example: + +`no newline after this line` \ No newline at end of file diff --git a/internal/lint/specs/pages/failing/010.md b/internal/lint/specs/pages/failing/010.md new file mode 100644 index 0000000..eafe1d8 --- /dev/null +++ b/internal/lint/specs/pages/failing/010.md @@ -0,0 +1,7 @@ +# du + +> This file has dos line endings. + +- A lot of them: + +`about 7 I'd say` diff --git a/internal/lint/specs/pages/failing/011.md b/internal/lint/specs/pages/failing/011.md new file mode 100644 index 0000000..475d7fc --- /dev/null +++ b/internal/lint/specs/pages/failing/011.md @@ -0,0 +1,9 @@ +# du + + +> Look at all that space. + + +- Here goes an example: + +`blub` diff --git a/internal/lint/specs/pages/failing/012.md b/internal/lint/specs/pages/failing/012.md new file mode 100644 index 0000000..f75a550 --- /dev/null +++ b/internal/lint/specs/pages/failing/012.md @@ -0,0 +1,3 @@ +# du + +> Look at all them tabs. diff --git a/internal/lint/specs/pages/failing/013.md b/internal/lint/specs/pages/failing/013.md new file mode 100644 index 0000000..b86b8a2 --- /dev/null +++ b/internal/lint/specs/pages/failing/013.md @@ -0,0 +1,3 @@ +# This is not a proper title. + +> Estimate file space usage. diff --git a/internal/lint/specs/pages/failing/014.md b/internal/lint/specs/pages/failing/014.md new file mode 100644 index 0000000..4aa88fd --- /dev/null +++ b/internal/lint/specs/pages/failing/014.md @@ -0,0 +1,12 @@ +# nix-env + +> Manipulate or query Nix user environments. +> More information: . + +- Show the status of available packages: + +`nix-env -qas` + +- Install package: + +`nix-env -i {{pkg_name}}` diff --git a/internal/lint/specs/pages/failing/015.md b/internal/lint/specs/pages/failing/015.md new file mode 100644 index 0000000..d5aa61c --- /dev/null +++ b/internal/lint/specs/pages/failing/015.md @@ -0,0 +1,7 @@ +# du + +> Estimate file space usage. + +- where dat space: + +`du` diff --git a/internal/lint/specs/pages/failing/016.md b/internal/lint/specs/pages/failing/016.md new file mode 100644 index 0000000..b416134 --- /dev/null +++ b/internal/lint/specs/pages/failing/016.md @@ -0,0 +1,8 @@ +# demo + +> Sample program. +> More info: . + +- Run demo: + +`demo` diff --git a/internal/lint/specs/pages/failing/017.md b/internal/lint/specs/pages/failing/017.md new file mode 100644 index 0000000..e1ca3f7 --- /dev/null +++ b/internal/lint/specs/pages/failing/017.md @@ -0,0 +1,8 @@ +# demo + +> Sample program. +> More information: https://not.real.invalid + +- Run demo: + +`demo` diff --git a/internal/lint/specs/pages/failing/018.md b/internal/lint/specs/pages/failing/018.md new file mode 100644 index 0000000..fce31a2 --- /dev/null +++ b/internal/lint/specs/pages/failing/018.md @@ -0,0 +1,10 @@ +# demo + +> Sample program. +> More information: . +> More information: . +> More information: . + +- Run demo: + +`demo` diff --git a/internal/lint/specs/pages/failing/019.md b/internal/lint/specs/pages/failing/019.md new file mode 100644 index 0000000..2d0be1a --- /dev/null +++ b/internal/lint/specs/pages/failing/019.md @@ -0,0 +1,40 @@ +# demo + +> Sample program. +> More information: . + +- Example 1: + +`demo` + +- Example 2: + +`demo` + +- Example 3: + +`demo` + +- Example 4: + +`demo` + +- Example 5: + +`demo` + +- Example 6: + +`demo` + +- Example 7: + +`demo` + +- Example 8: + +`demo` + +- Example 9: + +`demo` diff --git a/internal/lint/specs/pages/failing/020.md b/internal/lint/specs/pages/failing/020.md new file mode 100644 index 0000000..434f494 --- /dev/null +++ b/internal/lint/specs/pages/failing/020.md @@ -0,0 +1,9 @@ +# demo + +> Sample program (note: this should error). +> NOTE: this should not pass. +> More information: . + +- Example 1 (note: this should error): + +`demo` diff --git a/internal/lint/specs/pages/failing/021.md b/internal/lint/specs/pages/failing/021.md new file mode 100644 index 0000000..4837d7a --- /dev/null +++ b/internal/lint/specs/pages/failing/021.md @@ -0,0 +1,16 @@ +# demo + +> Sample program. +> More information: . + +- Example 1 should fail: + +` demo` + +- Example 2 should fail: + +`demo ` + +- Example 3 should pass: + +`demo \ ` diff --git a/internal/lint/specs/pages/failing/101.md b/internal/lint/specs/pages/failing/101.md new file mode 100644 index 0000000..57f8e53 --- /dev/null +++ b/internal/lint/specs/pages/failing/101.md @@ -0,0 +1,7 @@ +# jar + +JAR (Java Archive) is a package file format used to distribute application software or libraries on the Java platform. + +- Unzip *.jar/*.war file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/102.md b/internal/lint/specs/pages/failing/102.md new file mode 100644 index 0000000..9ef4100 --- /dev/null +++ b/internal/lint/specs/pages/failing/102.md @@ -0,0 +1,7 @@ +# jar + +> JAR (Java Archive) is a package file format. + +Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/103.md b/internal/lint/specs/pages/failing/103.md new file mode 100644 index 0000000..015daa7 --- /dev/null +++ b/internal/lint/specs/pages/failing/103.md @@ -0,0 +1,11 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar + +- Blub the blubbipity: + +`blub {{bluppity}} diff --git a/internal/lint/specs/pages/failing/104.md b/internal/lint/specs/pages/failing/104.md new file mode 100644 index 0000000..6b94ef0 --- /dev/null +++ b/internal/lint/specs/pages/failing/104.md @@ -0,0 +1,23 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzips file to the current directory: + +`jar` + +- Ping someone: + +`ping` + +- Writing hello world: + +`echo` + +- Pass a value: + +`blub` + +- Always do something: + +`gimp` diff --git a/internal/lint/specs/pages/failing/105.md b/internal/lint/specs/pages/failing/105.md new file mode 100644 index 0000000..30ae479 --- /dev/null +++ b/internal/lint/specs/pages/failing/105.md @@ -0,0 +1,9 @@ +# du + +> Estimate file space usage. + +- Here goes an example: + +`there are really a lot of ways` +`such a long list` +`a loooot` diff --git a/internal/lint/specs/pages/failing/106.md b/internal/lint/specs/pages/failing/106.md new file mode 100644 index 0000000..ad7a37d --- /dev/null +++ b/internal/lint/specs/pages/failing/106.md @@ -0,0 +1,7 @@ +jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/107 b/internal/lint/specs/pages/failing/107 new file mode 100644 index 0000000..bddc923 --- /dev/null +++ b/internal/lint/specs/pages/failing/107 @@ -0,0 +1,7 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/108 .md b/internal/lint/specs/pages/failing/108 .md new file mode 100644 index 0000000..bddc923 --- /dev/null +++ b/internal/lint/specs/pages/failing/108 .md @@ -0,0 +1,7 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/109A.md b/internal/lint/specs/pages/failing/109A.md new file mode 100644 index 0000000..bddc923 --- /dev/null +++ b/internal/lint/specs/pages/failing/109A.md @@ -0,0 +1,7 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/110.md b/internal/lint/specs/pages/failing/110.md new file mode 100644 index 0000000..e84cbb6 --- /dev/null +++ b/internal/lint/specs/pages/failing/110.md @@ -0,0 +1,9 @@ +# tar + +> Archiving utility. +> Often combined with a compression method, such as gzip or bzip. +> More information: . + +- [c]reate an archive from [f]iles: + +`` diff --git a/internal/lint/specs/pages/failing/111.md b/internal/lint/specs/pages/failing/111.md new file mode 100644 index 0000000..bddc923 --- /dev/null +++ b/internal/lint/specs/pages/failing/111.md @@ -0,0 +1,7 @@ +# jar + +> JAR (Java Archive) is a package file format. + +- Unzip file to the current directory: + +`jar -xvf *.jar` diff --git a/internal/lint/specs/pages/failing/112.md b/internal/lint/specs/pages/failing/112.md new file mode 100644 index 0000000..e58da06 --- /dev/null +++ b/internal/lint/specs/pages/failing/112.md @@ -0,0 +1,29 @@ +# cat + +> Print and concatenate files to stdout. +> This command is an alias of `gzip --stdout --decompress`. +> More information: . + +- Print the contents of a file to stdout: + +`cat {{path/to/file}}` + +- Redirect Standard in to a file: + +`cat > {{path/to/file}}` + +- Write standard error to a file: + +`command 2> {{path/to/file}}` + +- Decompress a file and write to stdout (implies `--keep`): + +`xz {{[-d|--decompress]}} {{[-c|--stdout]}} {{path/to/file.xz}}` + +- Test if a given string conforms the specified regex: + +`[[ ${{variable}} =~ {{pattern}} ]]` + +- Test if a given string conforms the specified RegEx: + +`[[ ${{variable}} =~ {{pattern}} ]]` diff --git a/internal/lint/specs/pages/passing/!.md b/internal/lint/specs/pages/passing/!.md new file mode 100644 index 0000000..271ba4e --- /dev/null +++ b/internal/lint/specs/pages/passing/!.md @@ -0,0 +1,36 @@ +# ! + +> Reuse and expand the shell history in `sh`, Bash, Zsh, `rbash` and `ksh`. +> More information: . + +- Substitute with the previous command and run it with `sudo`: + +`sudo !!` + +- Substitute with a command based on its line number found with `history`: + +`!{{number}}` + +- Substitute with a command that was used a specified number of lines back: + +`!-{{number}}` + +- Substitute with the most recent command that starts with a string: + +`!{{string}}` + +- Substitute with the arguments of the latest command: + +`{{command}} !*` + +- Substitute with the last argument of the latest command: + +`{{command}} !$` + +- Substitute with the last command but without the last argument: + +`!:-` + +- Print last command that starts with a string without executing it: + +`!{{string}}:p` diff --git a/internal/lint/specs/pages/passing/$.md b/internal/lint/specs/pages/passing/$.md new file mode 100644 index 0000000..4b4816b --- /dev/null +++ b/internal/lint/specs/pages/passing/$.md @@ -0,0 +1,32 @@ +# $ + +> Expand a Bash variable. +> More information: . + +- Print a variable: + +`echo ${{VARIABLE}}` + +- Print the exit status of the previous command: + +`echo $?` + +- Print a random number between 0 and 32767: + +`echo $RANDOM` + +- Print one of the prompt strings: + +`echo ${{PS0|PS1|PS2|PS3|PS4}}` + +- Expand with the output of `command` and run it. Same as enclosing `command` in backtics: + +`$({{command}})` + +- List how many arguments the current context has: + +`echo $#` + +- Print out a Bash array: + +`echo ${array[@]}` diff --git a/internal/lint/specs/pages/passing/%.md b/internal/lint/specs/pages/passing/%.md new file mode 100644 index 0000000..1203679 --- /dev/null +++ b/internal/lint/specs/pages/passing/%.md @@ -0,0 +1,28 @@ +# % + +> Manage jobs. +> More information: . + +- Bring the current job to front: + +`%` + +- Bring the previous job to front: + +`%-` + +- Bring the job number `n` to front: + +`%{{n}}` + +- Bring a job whose command starts with `string` to front: + +`%{{string}}` + +- Bring a job whose command contains `string` to front: + +`%?{{string}}` + +- Resume a suspended job: + +`%{{1}} &` diff --git a/internal/lint/specs/pages/passing/((.md b/internal/lint/specs/pages/passing/((.md new file mode 100644 index 0000000..3b192c5 --- /dev/null +++ b/internal/lint/specs/pages/passing/((.md @@ -0,0 +1,7 @@ +# (( + +> This command is an alias of `let`. + +- View documentation for the original command: + +`tldr let` diff --git a/internal/lint/specs/pages/passing/+.md b/internal/lint/specs/pages/passing/+.md new file mode 100644 index 0000000..ce8bbad --- /dev/null +++ b/internal/lint/specs/pages/passing/+.md @@ -0,0 +1,21 @@ +# title++ + +> Compiles C++ source files. +> Part of GCC (GNU Compiler Collection). +> More information: . + +- Compile a source code file into an executable binary: + +`g++ {{source.cpp}} -o {{output_executable}}` + +- Display (almost) all errors and warnings: + +`g++ {{source.cpp}} -Wall -o {{output_executable}}` + +- Choose a language standard to compile for(C++98/C++11/C++14/C++17): + +`g++ {{source.cpp}} -std={{language_standard}} -o {{output_executable}}` + +- Include libraries located at a different path than the source file: + +`g++ {{source.cpp}} -o {{output_executable}} -I{{header_path}} -L{{library_path}} -l{{library_name}}` diff --git a/internal/lint/specs/pages/passing/,.md b/internal/lint/specs/pages/passing/,.md new file mode 100644 index 0000000..3d81b31 --- /dev/null +++ b/internal/lint/specs/pages/passing/,.md @@ -0,0 +1,16 @@ +# , + +> Run commands without installing them. +> More information: . + +- Run a command: + +`, {{command -with -flags}}` + +- Add a command to a child shell: + +`, {{[-s|--shell]}} {{command}}` + +- Clear the cache: + +`, {{[-e|--empty-cache]}}` diff --git a/internal/lint/specs/pages/passing/..md b/internal/lint/specs/pages/passing/..md new file mode 100644 index 0000000..5ac9812 --- /dev/null +++ b/internal/lint/specs/pages/passing/..md @@ -0,0 +1,7 @@ +# . + +> This command is an alias of `source`. + +- View documentation for the original command: + +`tldr source` diff --git a/internal/lint/specs/pages/passing/[.md b/internal/lint/specs/pages/passing/[.md new file mode 100644 index 0000000..c65898d --- /dev/null +++ b/internal/lint/specs/pages/passing/[.md @@ -0,0 +1,33 @@ +# [ + +> Check file types and compare values. +> Returns a status of 0 if the condition evaluates to true, 1 if it evaluates to false. +> More information: . + +- Test if a given variable is equal/not equal to the specified string: + +`[ "${{variable}}" {{=|!=}} "{{string}}" ]` + +- Test if a given variable is [eq]ual/[n]ot [e]qual/[g]reater [t]han/[l]ess [t]han/[g]reater than or [e]qual/[l]ess than or [e]qual to the specified number: + +`[ "${{variable}}" -{{eq|ne|gt|lt|ge|le}} {{integer}} ]` + +- Test if the specified variable has a [n]on-empty value: + +`[ -n "${{variable}}" ]` + +- Test if the specified variable has an empty value ([z]ero length): + +`[ -z "${{variable}}" ]` + +- Test if the specified [f]ile exists: + +`[ -f {{path/to/file}} ]` + +- Test if the specified [d]irectory exists: + +`[ -d {{path/to/directory}} ]` + +- Test if the specified file or directory [e]xists: + +`[ -e {{path/to/file_or_directory}} ]` diff --git a/internal/lint/specs/pages/passing/[[.md b/internal/lint/specs/pages/passing/[[.md new file mode 100644 index 0000000..0ebe569 --- /dev/null +++ b/internal/lint/specs/pages/passing/[[.md @@ -0,0 +1,37 @@ +# [[ + +> Check file types and compare values. +> Returns a status of 0 if the condition evaluates to true, 1 if it evaluates to false. +> More information: . + +- Test if a given variable is equal/not equal to the specified string: + +`[[ ${{variable}} {{==|!=}} "{{string}}" ]]` + +- Test if a given string conforms the specified `regex`: + +`[[ ${{variable}} =~ {{pattern}} ]]` + +- Test if a given variable is [eq]ual/[n]ot [e]qual/[g]reater [t]han/[l]ess [t]han/[g]reater than or [e]qual/[l]ess than or [e]qual to the specified number: + +`[[ ${{variable}} -{{eq|ne|gt|lt|ge|le}} {{integer}} ]]` + +- Test if the specified variable has a [n]on-empty value: + +`[[ -n ${{variable}} ]]` + +- Test if the specified variable has an empty value ([z]ero length): + +`[[ -z ${{variable}} ]]` + +- Test if the specified [f]ile exists: + +`[[ -f {{path/to/file}} ]]` + +- Test if the specified [d]irectory exists: + +`[[ -d {{path/to/directory}} ]]` + +- Test if the specified file or directory [e]xists: + +`[[ -e {{path/to/file_or_directory}} ]]` diff --git a/internal/lint/specs/pages/passing/].md b/internal/lint/specs/pages/passing/].md new file mode 100644 index 0000000..3b120d0 --- /dev/null +++ b/internal/lint/specs/pages/passing/].md @@ -0,0 +1,7 @@ +# ] + +> This shell keyword is used to close out `[`. + +- View documentation for the `[` keyword: + +`tldr [` diff --git a/internal/lint/specs/pages/passing/]].md b/internal/lint/specs/pages/passing/]].md new file mode 100644 index 0000000..5a8af3b --- /dev/null +++ b/internal/lint/specs/pages/passing/]].md @@ -0,0 +1,7 @@ +# ]] + +> This shell keyword is used to close out `[[`. + +- View documentation for the `[[` keyword: + +`tldr [[` diff --git a/internal/lint/specs/pages/passing/^.md b/internal/lint/specs/pages/passing/^.md new file mode 100644 index 0000000..56a8b0c --- /dev/null +++ b/internal/lint/specs/pages/passing/^.md @@ -0,0 +1,21 @@ +# ^ + +> Bash builtin to quick substitute a string in the previous command and run the result. +> Equivalent to `!!:s^string1^string2`. +> More information: . + +- Run the previous command replacing `string1` with `string2`: + +`^{{string1}}^{{string2}}` + +- Remove `string1` from the previous command: + +`^{{string1}}^` + +- Replace `string1` with `string2` in the previous command and add `string3` to its end: + +`^{{string1}}^{{string2}}^{{string3}}` + +- Replace all occurrences of `string1`: + +`^{{string1}}^{{string2}}^:&` diff --git a/internal/lint/specs/pages/passing/bracket.md b/internal/lint/specs/pages/passing/bracket.md new file mode 100644 index 0000000..c4450b3 --- /dev/null +++ b/internal/lint/specs/pages/passing/bracket.md @@ -0,0 +1,9 @@ +# tar + +> Archiving utility. +> Often combined with a compression method, such as gzip or bzip. +> More information: . + +- [c]reate an archive from [f]iles: + +`tar cf {{target.tar}} {{file1}} {{file2}} {{file3}}` diff --git a/internal/lint/specs/pages/passing/colon.md b/internal/lint/specs/pages/passing/colon.md new file mode 100644 index 0000000..33e6f22 --- /dev/null +++ b/internal/lint/specs/pages/passing/colon.md @@ -0,0 +1,12 @@ +# : + +> Returns a successful exit status code of 0. +> More information: . + +- Return a successful exit code: + +`:` + +- Make a command always exit with 0: + +`{{command}} || :` diff --git a/internal/lint/specs/pages/passing/descriptions.md b/internal/lint/specs/pages/passing/descriptions.md new file mode 100644 index 0000000..29c6eb6 --- /dev/null +++ b/internal/lint/specs/pages/passing/descriptions.md @@ -0,0 +1,5 @@ +# du + +> Estimate file space usage. +> This just goes on (Note: this is a note). +> Because its so important. diff --git a/internal/lint/specs/pages/passing/greater-than.md b/internal/lint/specs/pages/passing/greater-than.md new file mode 100644 index 0000000..4d345e7 --- /dev/null +++ b/internal/lint/specs/pages/passing/greater-than.md @@ -0,0 +1,28 @@ +# > + +> Redirect output. +> More information: . + +- Redirect `stdout` to a file: + +`{{command}} > {{path/to/file}}` + +- Append to a file: + +`{{command}} >> {{path/to/file}}` + +- Redirect both `stdout` and `stderr` to a file: + +`{{command}} &> {{path/to/file}}` + +- Redirect `stderr` to `/dev/null` to keep the terminal output clean: + +`{{command}} 2> /dev/null` + +- Clear the file contents or create a new empty file: + +`> {{path/to/file}}` + +- Redirect `stderr` to `stdout` for piping them together: + +`{{command1}} 2>&1 | {{command2}}` diff --git a/internal/lint/specs/pages/passing/less-than.md b/internal/lint/specs/pages/passing/less-than.md new file mode 100644 index 0000000..a844f33 --- /dev/null +++ b/internal/lint/specs/pages/passing/less-than.md @@ -0,0 +1,32 @@ +# < + +> Redirect data to `stdin`. +> More information: . + +- Redirect a file to `stdin` (achieves the same effect as `cat file.txt |`): + +`{{command}} < {{path/to/file.txt}}` + +- Create a here document and pass that into `stdin` (requires a multiline command): + +`{{command}} << {{EOF}} {{multiline_text}} {{EOF}}` + +- Create a here string and pass that into `stdin` (achieves the same effect as `echo string |`): + +`{{command}} <<< {{string}}` + +- Process data from a file and write the output to another file: + +`{{command}} < {{path/to/file.txt}} > {{path/to/file2.txt}}` + +- Write a here document into a file: + +`cat << {{EOF}} > {{path/to/file.txt}} {{multiline_data}} {{EOF}}` + +- Disregard leading tabs (good for scripts with indentation but does not work for spaces): + +`cat <<- {{EOF}} > {{path/to/file.txt}} {{multiline_data}} {{EOF}}` + +- Pass command output to a program as a file descriptor: + +`diff <({{command1}}) <({{command2}})` diff --git a/internal/lint/specs/pages/passing/lower-case.md b/internal/lint/specs/pages/passing/lower-case.md new file mode 100644 index 0000000..35ba924 --- /dev/null +++ b/internal/lint/specs/pages/passing/lower-case.md @@ -0,0 +1,4 @@ +# npm + +> npm is always written in lower case. +> This just goes on. diff --git a/internal/lint/specs/pages/passing/question-mark.md b/internal/lint/specs/pages/passing/question-mark.md new file mode 100644 index 0000000..7e3fe4e --- /dev/null +++ b/internal/lint/specs/pages/passing/question-mark.md @@ -0,0 +1,16 @@ +# ? + +> Get context sensitive. +> More information: . + +- Get available commands: + +`?` + +- Get storages that are listable: + +`dir ?` + +- Show what IP information is viewable: + +`ip show ?` diff --git a/internal/lint/specs/pages/passing/special-characters.md b/internal/lint/specs/pages/passing/special-characters.md new file mode 100644 index 0000000..83b01f6 --- /dev/null +++ b/internal/lint/specs/pages/passing/special-characters.md @@ -0,0 +1,9 @@ +# command + +> Test for special uppercase characters. +> For example French has these uppercase letters `Ê`, `À`, `Ė`, `Ç`, etc. +> More information: . + +- Éxecute une commande (exec a cmd in French): + +`command` diff --git a/internal/lint/specs/pages/passing/standardized-terms.md b/internal/lint/specs/pages/passing/standardized-terms.md new file mode 100644 index 0000000..c3364c4 --- /dev/null +++ b/internal/lint/specs/pages/passing/standardized-terms.md @@ -0,0 +1,25 @@ +# cat + +> Print and concatenate files to `stdout`. +> This command is an alias of `gzip --stdout --decompress`. +> More information: . + +- Print the contents of a file to `stdout`: + +`cat {{path/to/file}}` + +- Redirect `stdin` to a file: + +`cat > {{path/to/file}}` + +- Write `stderr` to a file: + +`command 2> {{path/to/file}}` + +- Decompress a file and write to `stdout` (implies `--keep`): + +`xz {{[-d|--decompress]}} {{[-c|--stdout]}} {{path/to/file.xz}}` + +- Test if a given string conforms the specified `regex`: + +`[[ ${{variable}} =~ {{pattern}} ]]` diff --git a/internal/lint/specs/pages/passing/vertical-bar.md b/internal/lint/specs/pages/passing/vertical-bar.md new file mode 100644 index 0000000..cb4e8a0 --- /dev/null +++ b/internal/lint/specs/pages/passing/vertical-bar.md @@ -0,0 +1,12 @@ +# | + +> Pipe data between programs. +> More information: . + +- Pipe `stdout` to `stdin`: + +`{{command}} | {{command}}` + +- Pipe both `stdout` and `stderr` to `stdin`: + +`{{command}} |& {{command}}` diff --git a/internal/lint/specs/pages/passing/{.md b/internal/lint/specs/pages/passing/{.md new file mode 100644 index 0000000..0d11b22 --- /dev/null +++ b/internal/lint/specs/pages/passing/{.md @@ -0,0 +1,36 @@ +# { + +> Multipurpose shell syntax. +> More information: . + +- Isolate variable names: + +`echo ${HOME}work` + +- Brace expand sequences: + +`echo {1..3} {a..c}{dir1,dir2,dir3}` + +- Check if `variable` is set before returning text: + +`echo ${variable:+variable is set and contains $variable}` + +- Set default values in case `variable` is unset: + +`echo ${variable:-default}` + +- Return `variable` length in characters: + +`echo ${#variable}` + +- Return a string slice: + +`echo ${variable:3:7}` + +- Recursively expand a `variable`: + +`echo ${!variable}` + +- Group command output together: + +`{ {{command1; command2; ...}} } | {{another_command}}` diff --git a/internal/lint/specs/pages/passing/}.md b/internal/lint/specs/pages/passing/}.md new file mode 100644 index 0000000..2f187be --- /dev/null +++ b/internal/lint/specs/pages/passing/}.md @@ -0,0 +1,7 @@ +# } + +> This shell keyword is used to close out `{`. + +- View documentation for the `{` keyword: + +`tldr {` diff --git a/internal/lint/specs/pages/passing/~.md b/internal/lint/specs/pages/passing/~.md new file mode 100644 index 0000000..8729f27 --- /dev/null +++ b/internal/lint/specs/pages/passing/~.md @@ -0,0 +1,16 @@ +# ~ + +> Expand to a directory. +> More information: . + +- List the current user's home directory contents: + +`ls ~` + +- List the home directory contents of another user: + +`ls ~{{username}}` + +- List the contents of the previous directory you were in: + +`ls ~-` From b5db45e71c06cbeb6502c2beaea5ebcb04b83581 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 10 Aug 2026 14:55:22 +0530 Subject: [PATCH 40/58] fix(lint): Avoid platform dependent filename checks --- internal/lint/filename_rules.go | 9 +++------ internal/lint/filename_rules_test.go | 10 ---------- internal/lint/lint.go | 27 +++++++++++++++++++++------ internal/lint/lint_test.go | 20 +++++--------------- 4 files changed, 29 insertions(+), 37 deletions(-) diff --git a/internal/lint/filename_rules.go b/internal/lint/filename_rules.go index 4864b6a..5814bb0 100644 --- a/internal/lint/filename_rules.go +++ b/internal/lint/filename_rules.go @@ -21,8 +21,7 @@ func checkFileExtension(filename string, r *Result) { // It reports an error if the filename // contains spaces or tab characters. func checkFilenameWhitespace(filename string, r *Result) { - base := filepath.Base(filename) - if strings.ContainsAny(base, " \t") { + if strings.ContainsAny(filename, " \t") { addError(r, "TLDR108", 0) } } @@ -32,8 +31,7 @@ func checkFilenameWhitespace(filename string, r *Result) { // It reports an error if the filename // contains uppercase letters. func checkFilenameLowercase(filename string, r *Result) { - base := filepath.Base(filename) - if base != strings.ToLower(base) { + if filename != strings.ToLower(filename) { addError(r, "TLDR109", 0) } } @@ -43,8 +41,7 @@ func checkFilenameLowercase(filename string, r *Result) { // It reports an error if filename // contains characters that are invalid on Windows filesystems. func checkForbiddenFilenameCharacters(filename string, r *Result) { - base := filepath.Base(filename) - if strings.ContainsAny(base, `<>:"/\|?*`) { + if strings.ContainsAny(filename, `<>:"/\|?*`) { addError(r, "TLDR111", 0) } } diff --git a/internal/lint/filename_rules_test.go b/internal/lint/filename_rules_test.go index c4ccf55..c65e5b8 100644 --- a/internal/lint/filename_rules_test.go +++ b/internal/lint/filename_rules_test.go @@ -66,11 +66,6 @@ func TestCheckFilenameWhitespace(t *testing.T) { filename: "tldr.md", wantCode: "", }, - { - name: "space in directory only passes", - filename: "my dir/tldr.md", - wantCode: "", - }, { name: "space in name fails", filename: "tldr page.md", @@ -151,11 +146,6 @@ func TestCheckForbiddenFilenameCharacters(t *testing.T) { filename: "tldr.md", wantCode: "", }, - { - name: "directory separator in path passes", - filename: "dir/tldr.md", - wantCode: "", - }, { name: "angle bracket fails", filename: "tldr:"\|?*` { + content, err := os.ReadFile(filepath.Join("specs", "pages", "failing", "111.md")) + require.NoError(t, err) + + for _, char := range `<>:"/\|?*` { t.Run( "111"+string(char), func(t *testing.T) { - content, err := os.ReadFile(filepath.Join("specs", "pages", "failing", "111.md")) - require.NoError(t, err) - - path := filepath.Join(t.TempDir(), "111"+string(char)+".md") - require.NoError(t, os.WriteFile(path, content, 0o600)) - - f, err := os.Open(path) - require.NoError(t, err) - defer func() { - _ = f.Close() - }() - - r, err := Lint(f) - require.NoError(t, err) + r := lint("111"+string(char)+".md", content) assertSpecErrors(t, r, []string{"TLDR111"}, 1, false) }, ) From f9e2f02a9500d3dc831dd6a25251f3218da2fcd9 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 10 Aug 2026 15:14:57 +0530 Subject: [PATCH 41/58] tests(cache): Add deterministic times to avoid brittle edge cases, remove unnecessary comments --- internal/cache/archive_test.go | 1 - internal/cache/cache_test.go | 10 ++++------ internal/cache/search_test.go | 4 ---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/internal/cache/archive_test.go b/internal/cache/archive_test.go index 3e456cf..a8b4f13 100644 --- a/internal/cache/archive_test.go +++ b/internal/cache/archive_test.go @@ -259,7 +259,6 @@ func TestExtractArchive(t *testing.T) { } } -// TestExtractFile tests the extractFile helper. func TestExtractFile(t *testing.T) { t.Parallel() diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 66ad056..2e4b03b 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -66,7 +66,6 @@ func TestNew(t *testing.T) { }) } -// TestDir tests Cache.Dir. func TestDir(t *testing.T) { t.Parallel() @@ -88,7 +87,6 @@ func TestDir(t *testing.T) { } } -// TestSubDirExists tests Cache.subDirExists. func TestSubDirExists(t *testing.T) { t.Parallel() @@ -163,7 +161,6 @@ func TestSubDirExists(t *testing.T) { } } -// TestGetPlatforms tests Cache.getPlatforms. func TestGetPlatforms(t *testing.T) { t.Parallel() @@ -290,7 +287,6 @@ func TestGetPlatforms(t *testing.T) { } } -// TestGetLanguageDirectories tests Cache.getLanguageDirectories. func TestGetLanguageDirectories(t *testing.T) { t.Parallel() @@ -377,7 +373,6 @@ func TestGetLanguageDirectories(t *testing.T) { } } -// TestLanguagesToDirectories tests Cache.languagesToDirectories. func TestLanguagesToDirectories(t *testing.T) { t.Parallel() @@ -540,7 +535,6 @@ func TestLanguagesToDirectories(t *testing.T) { } } -// TestCacheNeedsUpdate tests Cache.NeedsUpdate. func TestCacheNeedsUpdate(t *testing.T) { t.Parallel() @@ -571,6 +565,8 @@ func TestCacheNeedsUpdate(t *testing.T) { setupDir: func(t *testing.T) string { d := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + fresh := time.Now().Add(-time.Minute) + require.NoError(t, os.Chtimes(d, fresh, fresh)) return d }, maxAge: 336, @@ -581,6 +577,8 @@ func TestCacheNeedsUpdate(t *testing.T) { setupDir: func(t *testing.T) string { d := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(d, "pages.en"), 0o750)) + stale := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(d, stale, stale)) return d }, maxAge: 0, diff --git a/internal/cache/search_test.go b/internal/cache/search_test.go index 7493cbc..8e69136 100644 --- a/internal/cache/search_test.go +++ b/internal/cache/search_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestSearch tests Cache.Search. func TestSearch(t *testing.T) { t.Parallel() @@ -165,7 +164,6 @@ func TestSearch(t *testing.T) { } } -// TestResolvePlatforms tests Cache.resolvePlatforms. func TestResolvePlatforms(t *testing.T) { t.Parallel() @@ -236,7 +234,6 @@ func TestResolvePlatforms(t *testing.T) { } } -// TestPlatformExists tests platformExists. func TestPlatformExists(t *testing.T) { t.Parallel() @@ -274,7 +271,6 @@ func TestPlatformExists(t *testing.T) { } } -// TestSearchDirectory tests Cache.searchDirectory. func TestSearchDirectory(t *testing.T) { t.Parallel() From 9f8d31e171f3fada120f821ae9e67455578c2c32 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 10 Aug 2026 19:05:13 +0530 Subject: [PATCH 42/58] tests(cache): Refactor similar tests into table driven tests --- internal/cache/cache_test.go | 93 +++++++++++++++++------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 2e4b03b..5cc7aa7 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -14,56 +14,49 @@ import ( ) func TestNew(t *testing.T) { - t.Run("from_initialized_config", func(t *testing.T) { - config.ResetForTesting() - defer config.ResetForTesting() - - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - err := os.WriteFile(cfgPath, []byte("[cache]\ndir = \"/custom/cache\"\n"), 0o644) - require.NoError(t, err) - - t.Setenv("TLGC_CONFIG", cfgPath) - err = config.Initialize() - require.NoError(t, err) - - c := New() - assert.Equal(t, "/custom/cache", c.Dir()) - }) - - t.Run("default_dir_when_not_in_config", func(t *testing.T) { - config.ResetForTesting() - defer config.ResetForTesting() - - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - err := os.WriteFile(cfgPath, []byte("[output]\nshow_title = false\n"), 0o644) - require.NoError(t, err) - - t.Setenv("TLGC_CONFIG", cfgPath) - err = config.Initialize() - require.NoError(t, err) - - c := New() - assert.Equal(t, config.Cache().Dir, c.Dir()) - }) - - t.Run("empty_dir_in_config", func(t *testing.T) { - config.ResetForTesting() - defer config.ResetForTesting() - - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - err := os.WriteFile(cfgPath, []byte("[cache]\ndir = \"\"\n"), 0o644) - require.NoError(t, err) - - t.Setenv("TLGC_CONFIG", cfgPath) - err = config.Initialize() - require.NoError(t, err) - - c := New() - assert.Equal(t, "", c.Dir()) - }) + tests := []struct { + name string + config []byte + want string + }{ + { + name: "from_initialized_config", + config: []byte("[cache]\ndir = \"/custom/cache\"\n"), + want: "/custom/cache", + }, + { + name: "default_dir_when_not_in_config", + config: []byte("[output]\nshow_title = false\n"), + want: config.Cache().Dir, + }, + { + name: "empty_dir_in_config", + config: []byte("[cache]\ndir = \"\"\n"), + want: "", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + config.ResetForTesting() + defer config.ResetForTesting() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfgPath, tt.config, 0o600) + require.NoError(t, err) + + t.Setenv("TLGC_CONFIG", cfgPath) + err = config.Initialize() + require.NoError(t, err) + + c := New() + assert.Equal(t, tt.want, c.Dir()) + }, + ) + } } func TestDir(t *testing.T) { From 4234b5ed7864026588e135297992c53d15e7c175 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 10 Aug 2026 23:29:35 +0530 Subject: [PATCH 43/58] lint: Format --- internal/lint/format.go | 105 +++++++++++++++ internal/lint/format_test.go | 248 +++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 internal/lint/format.go create mode 100644 internal/lint/format_test.go diff --git a/internal/lint/format.go b/internal/lint/format.go new file mode 100644 index 0000000..1bb7394 --- /dev/null +++ b/internal/lint/format.go @@ -0,0 +1,105 @@ +package lint + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Format renders a canonical tldr page from content. +// It mirrors the reference linter.format function in tldr-lint: +// titles are kept as-is, +// descriptions are capitalized +// and forced to end in a period, +// example descriptions are capitalized +// and forced to end in a colon, +// and commands are re-emitted verbatim. +// +// It returns the empty string for empty or whitespace-only content. +func Format(content string) string { + page := parse(content) + if page == nil { + return "" + } + + var b strings.Builder + b.WriteString("# ") + b.WriteString(page.title) + b.WriteString("\n\n") + + for _, line := range page.descriptions { + if isInfoLink(line.content) { + b.WriteString("> More information: ") + b.WriteString(angleBracketedURL(line.content)) + b.WriteString(".\n") + continue + } + b.WriteString("> ") + b.WriteString(formatDescription(line.content)) + b.WriteString("\n") + } + + for _, section := range page.exampleSections { + b.WriteString("\n- ") + b.WriteString(formatExampleDescription(section.description)) + b.WriteString("\n\n") + for _, command := range section.commands { + b.WriteString("`") + b.WriteString(command.content) + b.WriteString("`\n") + } + } + + return b.String() +} + +// formatDescription capitalizes the first character +// and forces a trailing period, +// mirroring the reference lexer which strips +// a single trailing punctuation character first. +func formatDescription(s string) string { + s = stripTrailing(s, ".,;!?") + return upperFirst(s) + "." +} + +// formatExampleDescription capitalizes the first character +// and forces a trailing colon, +// mirroring the reference lexer which strips +// a single trailing punctuation character first. +func formatExampleDescription(s string) string { + s = stripTrailing(s, ".:,;") + return upperFirst(s) + ":" +} + +// upperFirst returns s with its first character uppercased. +func upperFirst(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[size:] +} + +// stripTrailing removes a single trailing character +// of s when it is one of cut. +func stripTrailing(s, cut string) string { + if n := len(s); n > 0 && + strings.ContainsRune(cut, rune(s[n-1])) { + return s[:n-1] + } + return s +} + +// angleBracketedURL returns the <...> substring +// of an information link line, +// or the empty string if absent. +func angleBracketedURL(line string) string { + openIndex := strings.Index(line, "<") + if openIndex < 0 || + !strings.Contains(line[openIndex:], ">") { + return "" + } + + closeIndex := strings.Index(line[openIndex:], ">") + return line[openIndex : openIndex+closeIndex+1] +} diff --git a/internal/lint/format_test.go b/internal/lint/format_test.go new file mode 100644 index 0000000..aeb6d72 --- /dev/null +++ b/internal/lint/format_test.go @@ -0,0 +1,248 @@ +package lint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want string + }{ + { + name: "empty", + content: "", + want: "", + }, + { + name: "whitespace_only", + content: " \n\t\n", + want: "", + }, + { + name: "full_page", + content: `# test + +> Test description. +> More information: . + +- Example: + +` + "`grep {{pattern}} {{file}}`" + ` + +- Second example: + +` + "`echo hello`" + ` +`, + want: `# test + +> Test description. +> More information: . + +- Example: + +` + "`grep {{pattern}} {{file}}`" + ` + +- Second example: + +` + "`echo hello`" + ` +`, + }, + { + name: "lowercase_description_is_capitalized_and_punctuated", + content: `# test + +> hello world + +- Example: + +` + "`echo hello`" + ` +`, + want: `# test + +> Hello world. + +- Example: + +` + "`echo hello`" + ` +`, + }, + { + name: "lowercase_example_desc_colon_added", + content: `# test + +> Test description. + +- example + +` + "`echo hello`" + ` +`, + want: `# test + +> Test description. + +- Example: + +` + "`echo hello`" + ` +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Format(tt.content) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormat_PreservesCommandText(t *testing.T) { + t.Parallel() + + page := `# test + +> Test description. +> More information: . + +- Example: + +` + "`grep {{pattern}} {{file}}`" + ` +` + + formatted := Format(page) + require.Contains(t, formatted, "`grep {{pattern}} {{file}}`") + require.NotContains(t, formatted, "undefined") +} + +func TestFormat_PreservesCommandWithoutPlaceholders(t *testing.T) { + t.Parallel() + + page := `# test + +> Test description. +> More information: . + +- Example: + +` + "`echo hello`" + ` +` + + formatted := Format(page) + require.Contains(t, formatted, "`echo hello`") + require.NotContains(t, formatted, "undefined") +} + +func TestFormatDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {in: "hello world", want: "Hello world."}, + {in: "Hello world", want: "Hello world."}, + {in: "already punctuated.", want: "Already punctuated."}, + {in: "ends with colon:", want: "Ends with colon:."}, + {in: "ελληνικά", want: "Ελληνικά."}, + {in: "", want: "."}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, formatDescription(tt.in)) + }) + } +} + +func TestFormatExampleDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {in: "example", want: "Example:"}, + {in: "Example", want: "Example:"}, + {in: "already punctuated:", want: "Already punctuated:"}, + {in: "ends with period.", want: "Ends with period:"}, + {in: "", want: ":"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, formatExampleDescription(tt.in)) + }) + } +} + +func TestUpperFirst(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {in: "", want: ""}, + {in: "hello", want: "Hello"}, + {in: "Hello", want: "Hello"}, + {in: "123abc", want: "123abc"}, + {in: "ελληνικά", want: "Ελληνικά"}, + {in: "hello WORLD", want: "Hello WORLD"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, upperFirst(tt.in)) + }) + } +} + +func TestStripTrailing(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + cut string + want string + }{ + {in: "hello", cut: ".,;!?", want: "hello"}, + {in: "hello.", cut: ".,;!?", want: "hello"}, + {in: "hello:", cut: ".,;!?", want: "hello:"}, + {in: "hello...", cut: ".,;!?", want: "hello.."}, + {in: "", cut: ".,;!?", want: ""}, + {in: "hello:", cut: ".:,;", want: "hello"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, stripTrailing(tt.in, tt.cut)) + }) + } +} + +func TestAngleBracketedURL(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {in: "More information: .", want: ""}, + {in: "no brackets", want: ""}, + {in: "open < only", want: ""}, + {in: "ac", want: ""}, + {in: "bare <>", want: "<>"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, angleBracketedURL(tt.in)) + }) + } +} From 62f2b0048ad198492c6755e87eb80401ee608e51 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 10 Aug 2026 23:33:36 +0530 Subject: [PATCH 44/58] test(lint): Tests for error strings --- internal/lint/lint_test.go | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/internal/lint/lint_test.go b/internal/lint/lint_test.go index ebda90e..9e434c9 100644 --- a/internal/lint/lint_test.go +++ b/internal/lint/lint_test.go @@ -118,6 +118,57 @@ func TestLintSpecsIgnore(t *testing.T) { assertSpecErrors(t, r, []string{"TLDR004"}, 2, false) } +func TestString(t *testing.T) { + tests := []struct { + name string + err Error + want string + }{ + { + name: "standard error", + err: Error{ + Code: "TLDR001", + Line: 10, + Description: "missing command description", + }, + want: "TLDR001:10 missing command description", + }, + { + name: "zero line", + err: Error{ + Code: "TLDR002", + Line: 0, + Description: "invalid page format", + }, + want: "TLDR002:0 invalid page format", + }, + { + name: "empty fields", + err: Error{}, + want: ":0 ", + }, + { + name: "description with punctuation", + err: Error{ + Code: "TLDR003", + Line: 42, + Description: "description must end with a period.", + }, + want: "TLDR003:42 description must end with a period.", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + got := tt.err.String() + require.Equal(t, tt.want, got) + }, + ) + } +} + // assertSpecErrors verifies that a lint result // contains the expected number of errors and error codes. // From ce58d3d7be42afac1b166c0899696b33506ed26b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 11 Aug 2026 00:30:33 +0530 Subject: [PATCH 45/58] cmd: Add flags/modifiers for --lint, --format --- cmd/cli.go | 18 +++++++ cmd/diagnostic.go | 11 +++- cmd/diagnostic_test.go | 20 +++++++ cmd/parse.go | 47 +++++++++++++++- cmd/parse_test.go | 120 +++++++++++++++++++++++++++++++++++++++++ cmd/validate.go | 33 +++++++++++- cmd/validate_test.go | 55 +++++++++++++++++++ 7 files changed, 301 insertions(+), 3 deletions(-) diff --git a/cmd/cli.go b/cmd/cli.go index 79e0c0a..b2d22bb 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -16,6 +16,12 @@ type CLI struct { // ListAll requests listing all pages across all platforms. ListAll bool + // Lint requests validating the specified tldr pages. + Lint bool + + // Format requests formatting the specified tldr pages. + Format bool + // Search requests a keyword search across pages. Search string @@ -57,6 +63,18 @@ type CLI struct { // Languages overrides the language list. Languages []string + // Output specifies the file to write formatted output to. + Output string + + // InPlace requests formatting files in place. + InPlace bool + + // Tabular requests displaying lint errors in tabular format. + Tabular bool + + // Ignore specifies comma-separated lint error codes to ignore. + Ignore []string + // ShortOptions requests displaying short option forms. ShortOptions bool diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index f5e1989..976546b 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -74,7 +74,10 @@ func flagDisplay(name string) string { // activeOps returns display names for all active operations in cli. func activeOps(cli *CLI) []string { var ops []string - if len(cli.Page) > 0 && !cli.Browse { + if len(cli.Page) > 0 && + !cli.Browse && + !cli.Lint && + !cli.Format { ops = append(ops, "[PAGE]...") } if cli.Update { @@ -86,6 +89,12 @@ func activeOps(cli *CLI) []string { if cli.ListAll { ops = append(ops, "--list-all") } + if cli.Lint { + ops = append(ops, "--lint ") + } + if cli.Format { + ops = append(ops, "--format ") + } if cli.Search != "" { ops = append(ops, "--search ") } diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index 5b4b09b..a6531d4 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -87,6 +87,11 @@ func TestFmtConflictError(t *testing.T) { cli: CLI{Page: []string{"tar"}, Search: "foo"}, contains: []string{"cannot be used with", "[PAGE]...", "--search"}, }, + { + name: "lint_and_format", + cli: CLI{Lint: true, Format: true, Page: []string{"file.md"}}, + contains: []string{"cannot be used with", "--lint ", "--format "}, + }, { name: "one_operation_fallback", cli: CLI{Update: true}, @@ -176,11 +181,26 @@ func TestActiveOps(t *testing.T) { cli: CLI{Render: "file.md"}, want: []string{"--render "}, }, + { + name: "lint", + cli: CLI{Lint: true, Page: []string{"file.md"}}, + want: []string{"--lint "}, + }, + { + name: "format", + cli: CLI{Format: true, Page: []string{"file.md"}}, + want: []string{"--format "}, + }, { name: "multiple", cli: CLI{Update: true, Search: "foo"}, want: []string{"--update", "--search "}, }, + { + name: "lint_with_update", + cli: CLI{Lint: true, Page: []string{"file.md"}, Update: true}, + want: []string{"--update", "--lint "}, + }, } for _, tt := range tests { diff --git a/cmd/parse.go b/cmd/parse.go index 1f72770..a5b1cd7 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -40,6 +40,20 @@ func parse(args []string) (*CLI, error) { "list all pages for the current platform", ) + fs.BoolVar( + &cli.Lint, + "lint", + false, + "validate the specified tldr pages", + ) + + fs.BoolVar( + &cli.Format, + "format", + false, + "format the specified tldr pages", + ) + fs.BoolVar(&cli.ListAll, "a", false, "list all pages") fs.BoolVar(&cli.ListAll, "list-all", false, "list all pages") @@ -145,6 +159,35 @@ func parse(args []string) (*CLI, error) { "specify the languages to use", ) + fs.StringVar( + &cli.Output, + "output", + "", + "write formatted output to the specified file", + ) + + fs.BoolVar( + &cli.InPlace, + "in-place", + false, + "formats pages in place", + ) + + fs.BoolVar( + &cli.Tabular, + "tabular", + false, + "format lint errors in a tabular format", + ) + + fs.Var( + &stringListValue{ + values: &cli.Ignore, + }, + "ignore", + "ignore comma-separated tldr lint error codes", + ) + fs.BoolVar( &cli.ShortOptions, "short-options", @@ -258,7 +301,9 @@ func reorderFlags(args []string) []string { "color", "config", "s", "search", - "r", "render": + "r", "render", + "output", + "ignore": if i+1 < len(args) { i++ flags = append(flags, args[i]) diff --git a/cmd/parse_test.go b/cmd/parse_test.go index 723b3c2..c5916a9 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -120,6 +120,22 @@ func TestParse(t *testing.T) { assert.Equal(t, "file.md", cli.Render) }, }, + { + name: "lint", + args: []string{"--lint", "pages/"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Lint) + assert.Equal(t, []string{"pages/"}, cli.Page) + }, + }, + { + name: "format", + args: []string{"--format", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Format) + assert.Equal(t, []string{"file.md"}, cli.Page) + }, + }, { name: "list_platforms", args: []string{"--list-platforms"}, @@ -355,6 +371,62 @@ func TestParse(t *testing.T) { assert.True(t, cli.LongOptions) }, }, + { + name: "output", + args: []string{"--output", "out.md", "--format", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, "out.md", cli.Output) + }, + }, + { + name: "in_place", + args: []string{"--in-place", "--format", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.InPlace) + }, + }, + { + name: "tabular", + args: []string{"--tabular", "--lint", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Tabular) + }, + }, + { + name: "ignore_comma", + args: []string{"--ignore", "TLDR001,TLDR002", "--lint", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"TLDR001", "TLDR002"}, cli.Ignore) + }, + }, + { + name: "ignore_repeat", + args: []string{"--ignore", "TLDR001", "--ignore", "TLDR002", "--lint", "file.md"}, + check: func(t *testing.T, cli *CLI) { + assert.Equal(t, []string{"TLDR001", "TLDR002"}, cli.Ignore) + }, + }, + { + name: "lint_with_all_options", + args: []string{"--lint", "file.md", "--in-place", "--tabular", "--ignore", "TLDR001"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Lint) + assert.True(t, cli.InPlace) + assert.True(t, cli.Tabular) + assert.Equal(t, []string{"TLDR001"}, cli.Ignore) + }, + }, + { + name: "format_with_all_options", + args: []string{"--format", "file.md", "--output", "out.md", "--in-place", "--tabular", "--ignore", "TLDR001"}, + check: func(t *testing.T, cli *CLI) { + assert.True(t, cli.Format) + assert.Equal(t, "out.md", cli.Output) + assert.True(t, cli.InPlace) + assert.True(t, cli.Tabular) + assert.Equal(t, []string{"TLDR001"}, cli.Ignore) + }, + }, // combined { @@ -431,6 +503,38 @@ func TestParse(t *testing.T) { assert.ErrorContains(t, err, "requires a page argument") }, }, + { + name: "lint_without_path", + args: []string{"--lint"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "requires a file or directory argument") + }, + }, + { + name: "format_without_path", + args: []string{"--format"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "requires a file or directory argument") + }, + }, + { + name: "output_without_format", + args: []string{"--output", "out.md", "file.md"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "requires --format") + }, + }, + { + name: "lint_and_format", + args: []string{"--lint", "file.md", "--format", "file.md"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "cannot be used with") + }, + }, { name: "browse_with_page", args: []string{"-b", "tar"}, @@ -500,6 +604,7 @@ func TestParse(t *testing.T) { } return } + require.NoError(t, err) require.NotNil(t, cli) if tt.check != nil { @@ -577,6 +682,21 @@ func TestReorderFlags(t *testing.T) { args: []string{"tar", "-s", "ngi"}, want: []string{"-s", "ngi", "tar"}, }, + { + name: "output_flag_after_positional", + args: []string{"file.md", "--output", "out.md"}, + want: []string{"--output", "out.md", "file.md"}, + }, + { + name: "ignore_flag_after_positional", + args: []string{"file.md", "--ignore", "TLDR001,TLDR002"}, + want: []string{"--ignore", "TLDR001,TLDR002", "file.md"}, + }, + { + name: "lint_output_ignore_after_positional", + args: []string{"file.md", "--lint", "--output", "out.md", "--ignore", "TLDR001"}, + want: []string{"--lint", "--output", "out.md", "--ignore", "TLDR001", "file.md"}, + }, } for _, tt := range tests { diff --git a/cmd/validate.go b/cmd/validate.go index 9a4f70e..c10a2b9 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -78,6 +78,28 @@ func validate(cli *CLI) error { ) } + // lint and format require a file or directory argument + if cli.Lint && len(cli.Page) == 0 { + return fmtUsage( + "flag %s requires a file or directory argument", + termcolor.Sprint("bold blue", "--lint"), + ) + } + if cli.Format && len(cli.Page) == 0 { + return fmtUsage( + "flag %s requires a file or directory argument", + termcolor.Sprint("bold blue", "--format"), + ) + } + + if cli.Output != "" && !cli.Format { + return fmtUsage( + "flag %s requires %s", + termcolor.Sprint("bold blue", "--output"), + termcolor.Sprint("bold blue", "--format"), + ) + } + return nil } @@ -85,7 +107,10 @@ func validate(cli *CLI) error { func (c *CLI) operationCount() int { count := 0 - if len(c.Page) > 0 && !c.Browse { + if len(c.Page) > 0 && + !c.Browse && + !c.Lint && + !c.Format { count++ } if c.Update { @@ -97,6 +122,12 @@ func (c *CLI) operationCount() int { if c.ListAll { count++ } + if c.Lint { + count++ + } + if c.Format { + count++ + } if c.Search != "" { count++ } diff --git a/cmd/validate_test.go b/cmd/validate_test.go index d0e570b..0c7e003 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -70,6 +70,33 @@ func TestValidate(t *testing.T) { cli: CLI{Color: "auto", Browse: true}, wantErr: true, }, + { + name: "lint_without_path", + cli: CLI{Color: "auto", Lint: true}, + wantErr: true, + }, + { + name: "lint_with_path", + cli: CLI{Color: "auto", Lint: true, Page: []string{"pages/"}}, + }, + { + name: "format_without_path", + cli: CLI{Color: "auto", Format: true}, + wantErr: true, + }, + { + name: "format_with_path", + cli: CLI{Color: "auto", Format: true, Page: []string{"file.md"}}, + }, + { + name: "output_without_format", + cli: CLI{Color: "auto", Output: "out.md", Page: []string{"file.md"}}, + wantErr: true, + }, + { + name: "output_with_format", + cli: CLI{Color: "auto", Format: true, Output: "out.md", Page: []string{"file.md"}}, + }, { name: "valid_color_auto", cli: CLI{Color: "auto", Update: true}, @@ -162,6 +189,21 @@ func TestOperationCount(t *testing.T) { cli: CLI{Render: "file.md"}, want: 1, }, + { + name: "lint", + cli: CLI{Lint: true, Page: []string{"file.md"}}, + want: 1, + }, + { + name: "format", + cli: CLI{Format: true, Page: []string{"file.md"}}, + want: 1, + }, + { + name: "lint_and_format", + cli: CLI{Lint: true, Format: true, Page: []string{"file.md"}}, + want: 2, + }, { name: "clean_cache", cli: CLI{CleanCache: true}, @@ -201,6 +243,19 @@ func TestOperationCount(t *testing.T) { }, want: 12, }, + { + name: "all_lint_operations", + cli: CLI{ + Lint: true, + Format: true, + Output: "out.md", + InPlace: true, + Tabular: true, + Ignore: []string{"TLDR001"}, + Page: []string{"file.md"}, + }, + want: 2, + }, } for _, tt := range tests { From 10d62f498c87c4a2afa8bfe455967ab0493533eb Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 11 Aug 2026 00:46:04 +0530 Subject: [PATCH 46/58] test(cache): Fix brittle test on info_test --- internal/cache/info_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/cache/info_test.go b/internal/cache/info_test.go index fb80361..5a5ef73 100644 --- a/internal/cache/info_test.go +++ b/internal/cache/info_test.go @@ -76,6 +76,9 @@ func TestInfo(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "apt.md"), nil, 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "pages.en", "linux", "pacman.md"), nil, 0o644)) + past := time.Now().Add(-1 * time.Hour) + require.NoError(t, os.Chtimes(dir, past, past)) + c := &Cache{dir: dir} info, err := c.Info() require.NoError(t, err) @@ -91,7 +94,8 @@ func TestInfo(t *testing.T) { assert.NotEmpty(t, info.Platforms) assert.Contains(t, info.Platforms, "common") assert.Contains(t, info.Platforms, "linux") - assert.Greater(t, info.AgeDuration, time.Duration(0)) + assert.Greater(t, info.AgeDuration, 55*time.Minute) + assert.Less(t, info.AgeDuration, 65*time.Minute) assert.Len(t, info.LanguageStats[0].Platforms, 2) assert.Equal(t, "common", info.LanguageStats[0].Platforms[0].Name) assert.Equal(t, 1, info.LanguageStats[0].Platforms[0].Pages) From fb6b1271d4b615c604afbf0450fdda69c194b2da Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 12 Aug 2026 13:03:54 +0530 Subject: [PATCH 47/58] chore --- cmd/parse_test.go | 6 +++--- cmd/validate.go | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/parse_test.go b/cmd/parse_test.go index c5916a9..6b6dd62 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -500,7 +500,7 @@ func TestParse(t *testing.T) { args: []string{"-b"}, wantErr: true, errCheck: func(t *testing.T, err error) { - assert.ErrorContains(t, err, "requires a page argument") + assert.ErrorContains(t, err, "requires a page") }, }, { @@ -508,7 +508,7 @@ func TestParse(t *testing.T) { args: []string{"--lint"}, wantErr: true, errCheck: func(t *testing.T, err error) { - assert.ErrorContains(t, err, "requires a file or directory argument") + assert.ErrorContains(t, err, "requires a file or directory") }, }, { @@ -516,7 +516,7 @@ func TestParse(t *testing.T) { args: []string{"--format"}, wantErr: true, errCheck: func(t *testing.T, err error) { - assert.ErrorContains(t, err, "requires a file or directory argument") + assert.ErrorContains(t, err, "requires a file or directory") }, }, { diff --git a/cmd/validate.go b/cmd/validate.go index c10a2b9..1a5bb8f 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -70,24 +70,24 @@ func validate(cli *CLI) error { return fmtConflictError(cli) } - // browse requires a page argument + // browse requires a page if cli.Browse && len(cli.Page) == 0 { return fmtUsage( - "flag %s requires a page argument", + "flag %s requires a page", termcolor.Sprint("bold blue", "--browse"), ) } - // lint and format require a file or directory argument + // lint and format require a file or directory if cli.Lint && len(cli.Page) == 0 { return fmtUsage( - "flag %s requires a file or directory argument", + "flag %s requires a file or directory", termcolor.Sprint("bold blue", "--lint"), ) } if cli.Format && len(cli.Page) == 0 { return fmtUsage( - "flag %s requires a file or directory argument", + "flag %s requires a file or directory", termcolor.Sprint("bold blue", "--format"), ) } From 1db128080f712dbac05e8aafe1f4f2d3e4ff95c4 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 12 Aug 2026 17:47:51 +0530 Subject: [PATCH 48/58] fix(lint): Reach flag dependency error for --output before parsing --- cmd/diagnostic.go | 12 +++++++++++- cmd/diagnostic_test.go | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 976546b..36ccee2 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -32,7 +32,17 @@ func fmtFlagError(fs *flag.FlagSet, err error) error { case strings.HasPrefix(s, "flag needs an argument: "): raw := strings.TrimPrefix(s, "flag needs an argument: ") name := strings.TrimLeft(raw, "-") - return fmtUsage("flag %s requires an argument", termcolor.Sprint("bold blue", flagDisplay(name))) + if name == "output" { + return fmtUsage( + "flag %s requires %s", + termcolor.Sprint("bold blue", "--output"), + termcolor.Sprint("bold blue", "--format"), + ) + } + return fmtUsage( + "flag %s requires an argument", + termcolor.Sprint("bold blue", flagDisplay(name)), + ) default: return fmtUsage("%s", s) } diff --git a/cmd/diagnostic_test.go b/cmd/diagnostic_test.go index a6531d4..f6f7c7d 100644 --- a/cmd/diagnostic_test.go +++ b/cmd/diagnostic_test.go @@ -49,6 +49,16 @@ func TestFmtFlagError(t *testing.T) { "requires an argument", }, }, + { + name: "missing_argument_output", + setup: func(fs *flag.FlagSet) { + fs.String("output", "", "") + }, + args: []string{"--output"}, + contains: []string{ + "--format", + }, + }, } for _, tt := range tests { From 1d69364478b3aedb6695051d5d2598b8e8b08a70 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 12 Aug 2026 18:15:50 +0530 Subject: [PATCH 49/58] feat(app): Lint --- internal/app/app.go | 2 + internal/app/lint.go | 148 +++++++++++++++++++++++++++ internal/app/lint_test.go | 205 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 internal/app/lint.go create mode 100644 internal/app/lint_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 7b1a160..e8b7a2f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -99,6 +99,8 @@ func (a *App) dispatch(cli *cmd.CLI) int { return a.listPages(cli) case cli.ListAll: return a.listAllPages() + case cli.Lint: + return a.lintPages(cli) case cli.Search != "": return a.searchPages(cli) case cli.ListPlatforms: diff --git a/internal/app/lint.go b/internal/app/lint.go new file mode 100644 index 0000000..0fa24d7 --- /dev/null +++ b/internal/app/lint.go @@ -0,0 +1,148 @@ +package app + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/lint" + "github.com/TheRootDaemon/tlgc/logger" +) + +// lintPages validates the tldr pages +// under the given paths +// and reports any lint errors to stderr. +// Returns 0 when no errors are found, 1 otherwise. +func (a *App) lintPages(cli *cmd.CLI) int { + files, err := collectFiles(cli.Page) + if err != nil { + logger.Error("%v", err) + return 1 + } + + root, err := os.OpenRoot(".") + if err != nil { + logger.Error("%v", err) + return 1 + } + defer func() { + _ = root.Close() + }() + + failed := false + for _, file := range files { + f, err := root.Open(file) + if err != nil { + logger.Error("%v", err) + failed = true + continue + } + + result, err := lint.Lint(f, cli.Ignore...) + _ = f.Close() + if err != nil { + logger.Error("%v", err) + failed = true + continue + } + + for _, e := range result.Errors { + a.writeLintError(file, e, cli.Tabular) + failed = true + } + } + + if failed { + return 1 + } + + return 0 +} + +// writeLintError reports a single lint error to stderr +// in the default or tabular reference format. +func (a *App) writeLintError( + path string, + e lint.Error, + tabular bool, +) { + if tabular { + _, _ = fmt.Fprintf( + a.Stderr, + "%s\t%d\t%s\t%s\t\n", + path, + e.Line, + e.Code, + e.Description, + ) + return + } + + _, _ = fmt.Fprintf( + a.Stderr, + "%s:%d: %s %s\n", + path, + e.Line, + e.Code, + e.Description, + ) +} + +// collectFiles expands each input path +// into a flat list of page files. +// Individual .md files are included as-is, +// while directories are walked recursively +// to collect .md files. +func collectFiles(paths []string) ([]string, error) { + var files []string + + for _, path := range paths { + pathFiles, err := collectPathFiles(path) + if err != nil { + return nil, err + } + files = append(files, pathFiles...) + } + + return files, nil +} + +// collectPathFiles expands a single path into page files. +// A .md file is returned as-is, +// a directory is walked recursively for .md files, +// and other file types are ignored. +func collectPathFiles(path string) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !info.IsDir() { + if filepath.Ext(path) == ".md" { + return []string{path}, nil + } + return nil, nil + } + + var files []string + if err = filepath.WalkDir( + path, + func(entry string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && filepath.Ext(entry) == ".md" { + files = append(files, entry) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return files, nil +} diff --git a/internal/app/lint_test.go b/internal/app/lint_test.go new file mode 100644 index 0000000..7043452 --- /dev/null +++ b/internal/app/lint_test.go @@ -0,0 +1,205 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/lint" + "github.com/stretchr/testify/assert" +) + +func TestCollectFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T) ([]string, []string, bool) + }{ + { + name: "file_and_directory_combined", + setup: func(t *testing.T) ([]string, []string, bool) { + dir := filepath.Join(t.TempDir(), "pages") + assert.NoError(t, mkdirall(dir)) + + solo := filepath.Join(t.TempDir(), "solo.md") + md := filepath.Join(dir, "a.md") + other := filepath.Join(dir, "b.txt") + + touch(t, solo) + touch(t, md) + touch(t, other) + + return []string{solo, dir}, []string{solo, md}, false + }, + }, + { + name: "nonexistent_aborts", + setup: func(t *testing.T) ([]string, []string, bool) { + dir := t.TempDir() + md := filepath.Join(dir, "a.md") + touch(t, md) + + return []string{md, filepath.Join(dir, "nope")}, nil, true + }, + }, + { + name: "empty_input", + setup: func(t *testing.T) ([]string, []string, bool) { + return nil, nil, false + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + paths, want, wantErr := tt.setup(t) + + got, err := collectFiles(paths) + + if wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got) + }, + ) + } +} + +func TestCollectPathFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T) (string, []string, bool) + }{ + { + name: "single_md_file", + setup: func(t *testing.T) (string, []string, bool) { + path := filepath.Join(t.TempDir(), "a.md") + touch(t, path) + return path, []string{path}, false + }, + }, + { + name: "non_md_file_ignored", + setup: func(t *testing.T) (string, []string, bool) { + path := filepath.Join(t.TempDir(), "a.txt") + touch(t, path) + return path, nil, false + }, + }, + { + name: "directory_walked_recursively", + setup: func(t *testing.T) (string, []string, bool) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + assert.NoError(t, mkdirall(sub)) + + files := []string{ + filepath.Join(dir, "a.md"), + filepath.Join(dir, "b.txt"), + filepath.Join(sub, "c.md"), + } + + for _, f := range files { + touch(t, f) + } + return dir, []string{files[0], files[2]}, false + }, + }, + { + name: "empty_directory", + setup: func(t *testing.T) (string, []string, bool) { + return t.TempDir(), nil, false + }, + }, + { + name: "nonexistent_path", + setup: func(t *testing.T) (string, []string, bool) { + return filepath.Join(t.TempDir(), "nope"), nil, true + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, want, wantErr := tt.setup(t) + + got, err := collectPathFiles(path) + + if wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got) + }) + } +} + +func TestWriteLintError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + err lint.Error + tabular bool + want string + }{ + { + name: "plain_format", + path: "pages/a.md", + err: lint.Error{Code: "TLDR001", Line: 1, Description: "leading whitespace"}, + want: "pages/a.md:1: TLDR001 leading whitespace\n", + }, + { + name: "tabular_format", + path: "pages/a.md", + err: lint.Error{Code: "TLDR001", Line: 1, Description: "leading whitespace"}, + tabular: true, + want: "pages/a.md\t1\tTLDR001\tleading whitespace\t\n", + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + var buf bytes.Buffer + a := &App{ + Stderr: &buf, + } + + a.writeLintError(tt.path, tt.err, tt.tabular) + assert.Equal(t, tt.want, buf.String()) + }, + ) + } +} + +// touch creates an empty file at path with permissions 0600. +// It is intended for use in tests. +func touch(t *testing.T, path string) { + t.Helper() + assert.NoError(t, os.WriteFile(path, nil, 0o600)) +} + +// mkdirall creates path and any missing parent directories +// with permissions 0750. +func mkdirall(path string) error { + return os.MkdirAll(path, 0o750) +} From 98046752277c512b66c910d173d4141996f32e18 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 12 Aug 2026 18:24:02 +0530 Subject: [PATCH 50/58] chore(help): Add help strings, completions --- cmd/help.go | 52 +++++++++++++++++++++++++++++++------------ completions/_tldr | 3 +++ completions/tldr.bash | 30 +++++++++++++++---------- completions/tldr.fish | 3 +++ 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/cmd/help.go b/cmd/help.go index 3d8b182..b9d56e8 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -58,9 +58,24 @@ func printFlags() { description: "List all pages", }, { - short: "-s", - long: "--search", - arg: "", description: "Search for pages containing a keyword", + long: "--lint", + arg: "", + description: "Validate the specified tldr pages", + }, + { + long: "--tabular", + description: "Display lint errors in a tabular format", + }, + { + long: "--ignore", + arg: "", + description: "Ignore comma-separated lint error codes", + }, + { + short: "-s", + long: "--search", + arg: "", + description: "Search for pages containing a keyword", }, { short: "-b", @@ -82,9 +97,10 @@ func printFlags() { description: "Show cache information", }, { - short: "-r", - long: "--render", - arg: "", description: "Render the specified tldr page", + short: "-r", + long: "--render", + arg: "", + description: "Render the specified tldr page", }, { long: "--clean-cache", @@ -101,14 +117,16 @@ func printFlags() { description: "Print the default config path", }, { - short: "-p", - long: "--platform", - arg: "", description: "Specify the platform to use (linux, osx, windows, etc.)", + short: "-p", + long: "--platform", + arg: "", + description: "Specify the platform to use (linux, osx, windows, etc.)", }, { - short: "-L", - long: "--language", - arg: "", description: "Specify the languages to use", + short: "-L", + long: "--language", + arg: "", + description: "Specify the languages to use", }, { long: "--short-options", @@ -144,13 +162,19 @@ func printFlags() { long: "--raw", description: "Print pages in raw markdown instead of rendering them", }, - {long: "--no-raw", description: "Render pages instead of printing raw file contents (overrides --raw)"}, + { + long: "--no-raw", + description: "Render pages instead of printing raw file contents (overrides --raw)", + }, { short: "-q", long: "--quiet", description: "Suppress status messages and warnings", }, - {long: "--verbose...", description: "Be more verbose (can be specified twice)"}, + { + long: "--verbose...", + description: "Be more verbose (can be specified twice)", + }, { long: "--color", arg: "", diff --git a/completions/_tldr b/completions/_tldr index 5fdc7ef..8f6c566 100644 --- a/completions/_tldr +++ b/completions/_tldr @@ -20,6 +20,9 @@ _tldr() { {-u,--update}"[Update the cache]" \ {-l,--list}"[List all pages in the current platform]" \ {-a,--list-all}"[List all pages]" \ + --lint"[Validate the specified tldr pages]:FILE:_files" \ + --tabular"[Display lint errors in a tabular format]" \ + --ignore"[Ignore comma-separated lint error codes]" \ {-s,--search}"[Search for pages containing a keyword]" \ {-b,--browse}"[Open page in the default web browser]" \ --list-platforms"[List available platforms]" \ diff --git a/completions/tldr.bash b/completions/tldr.bash index a7173b2..3844ae5 100644 --- a/completions/tldr.bash +++ b/completions/tldr.bash @@ -2,10 +2,11 @@ _tldr() { local cur="${COMP_WORDS[COMP_CWORD]}" - local prev="${COMP_WORDS[COMP_CWORD-1]}" + local prev="${COMP_WORDS[COMP_CWORD - 1]}" local opts="-u -l -a -s -b -i -r -p -L -o -c -R -q -v -h \ - --update --list --list-all --search --browse --list-platforms --list-languages \ + --update --list --list-all --lint --tabular \ + --ignore --search --browse --list-platforms --list-languages \ --info --render --clean-cache --gen-config --config-path --platform \ --language --short-options --long-options --edit --offline --compact \ --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" @@ -16,16 +17,21 @@ _tldr() { fi case $prev in - -r|--render|--config) - mapfile -t COMPREPLY < <(compgen -f -- "$cur");; - --color) - mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur");; - -p|--platform) - mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-platforms 2> /dev/null)" -- "$cur");; - -L|--language) - mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-languages 2> /dev/null)" -- "$cur");; - *) - mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-all 2> /dev/null)" -- "$cur");; + -r | --render | --config | --lint) + mapfile -t COMPREPLY < <(compgen -f -- "$cur") + ;; + --color) + mapfile -t COMPREPLY < <(compgen -W "auto always never" -- "$cur") + ;; + -p | --platform) + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-platforms 2>/dev/null)" -- "$cur") + ;; + -L | --language) + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-languages 2>/dev/null)" -- "$cur") + ;; + *) + mapfile -t COMPREPLY < <(compgen -W "$(tldr --offline --list-all 2>/dev/null)" -- "$cur") + ;; esac } diff --git a/completions/tldr.fish b/completions/tldr.fish index 5aae6c7..2339a10 100644 --- a/completions/tldr.fish +++ b/completions/tldr.fish @@ -1,6 +1,9 @@ complete -c tldr -s u -l update -d "Update the cache" complete -c tldr -s l -l list -d "List all pages in the current platform" complete -c tldr -s a -l list-all -d "List all pages" +complete -c tldr -l lint -d "Validate the specified tldr pages" -r +complete -c tldr -l tabular -d "Display lint errors in a tabular format" +complete -c tldr -l ignore -d "Ignore comma-separated lint error codes" -r complete -c tldr -s s -l search -d "Search for pages containing a keyword" complete -c tldr -s b -l browse -d "Open page in the default web browser" complete -c tldr -l list-platforms -d "List available platforms" From a74a9f98346b9791891e1c92481c4a65a2b93042 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Thu, 13 Aug 2026 23:04:37 +0530 Subject: [PATCH 51/58] app: Format handlers, linting refactors --- internal/app/app.go | 26 +++- internal/app/collect_files.go | 76 ++++++++++ internal/app/collect_files_test.go | 162 ++++++++++++++++++++++ internal/app/format.go | 143 +++++++++++++++++++ internal/app/format_test.go | 142 +++++++++++++++++++ internal/app/lint.go | 119 +++++----------- internal/app/lint_test.go | 214 +++++++---------------------- internal/app/main_test.go | 41 ++++++ 8 files changed, 677 insertions(+), 246 deletions(-) create mode 100644 internal/app/collect_files.go create mode 100644 internal/app/collect_files_test.go create mode 100644 internal/app/format.go create mode 100644 internal/app/format_test.go create mode 100644 internal/app/main_test.go diff --git a/internal/app/app.go b/internal/app/app.go index e8b7a2f..2faa45d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,7 +11,12 @@ import ( "github.com/TheRootDaemon/tlgc/platform" ) -// App is the main application struct that holds I/O streams and configuration. +// App is the main application struct +// that holds I/O streams and configuration. +// +// It is constructed with New, +// which fills the streams with the process defaults, +// and customized through the With* options. type App struct { // Stdin is the reader for standard input. Stdin io.Reader @@ -74,6 +79,16 @@ func New(opts ...Option) *App { // Run initializes the configuration when required and dispatches // the parsed CLI command to the appropriate handler. +// +// Configuration is loaded +// unless the command is one of the few that must work without it +// (printing help, the version, or config-file paths). +// +// A configuration load failure is logged +// and turns into an exit code of 1. +// Otherwise the command is delegated +// to dispatch, whose result becomes the exit code. +// // It returns 0 on success and 1 on error. func (a *App) Run(cli *cmd.CLI) int { needsConfig := !cli.GenConfig && !cli.ConfigPath && !cli.ShowVersion && !cli.ShowHelp @@ -90,6 +105,13 @@ func (a *App) Run(cli *cmd.CLI) int { // dispatch routes the parsed CLI command to the corresponding handler // based on the provided flags. +// +// The cases are evaluated in order, +// so mutually exclusive operations resolve to the first matching flag: +// explicit operations (update, list, lint, format, search, and the rest) +// win over the bare page-lookup that handles positional arguments in cli.Page. +// When no case matches, the command is a no-op and returns 0. +// // It returns 0 on success and 1 on error. func (a *App) dispatch(cli *cmd.CLI) int { switch { @@ -101,6 +123,8 @@ func (a *App) dispatch(cli *cmd.CLI) int { return a.listAllPages() case cli.Lint: return a.lintPages(cli) + case cli.Format: + return a.formatPages(cli) case cli.Search != "": return a.searchPages(cli) case cli.ListPlatforms: diff --git a/internal/app/collect_files.go b/internal/app/collect_files.go new file mode 100644 index 0000000..cb55a21 --- /dev/null +++ b/internal/app/collect_files.go @@ -0,0 +1,76 @@ +package app + +import ( + "io/fs" + "os" + "path/filepath" +) + +// collectFiles expands every input path into a flat, ordered list of page +// files. +// +// Each path is resolved with collectPathFiles: +// an individual file is taken as-is regardless of its extension +// (a non-.md file passed directly is still linted and reported as TLDR107), +// while a directory is walked recursively +// to gather every .md file beneath it. +// Files are returned in the order the paths were given, +// with directory contents ordered as filepath.WalkDir yields them. +// +// It returns an error and no files if any input path cannot be stat'd. +func collectFiles(paths []string) ([]string, error) { + var files []string + + for _, path := range paths { + pathFiles, err := collectPathFiles(path) + if err != nil { + return nil, err + } + files = append(files, pathFiles...) + } + + return files, nil +} + +// collectPathFiles expands a single path into the list of page files it designates. +// +// A path naming an existing file is returned as-is, +// whatever its extension, so that callers can lint explicitly named non-.md files. +// A path naming a directory is walked recursively, +// collecting only entries whose extension is ".md"; +// subdirectories are descended automatically, +// and files of any other type are skipped. +// An empty directory yields an empty list. +// +// It returns an error if the path does not exist or cannot be stat'd, +// or if the directory walk fails partway through. +func collectPathFiles(path string) ([]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !info.IsDir() { + return []string{path}, nil + } + + var files []string + if err = filepath.WalkDir( + path, + func(entry string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && filepath.Ext(entry) == ".md" { + files = append(files, entry) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return files, nil +} diff --git a/internal/app/collect_files_test.go b/internal/app/collect_files_test.go new file mode 100644 index 0000000..e8dee18 --- /dev/null +++ b/internal/app/collect_files_test.go @@ -0,0 +1,162 @@ +package app + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCollectFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T) ([]string, []string, bool) + }{ + { + name: "file_and_directory_combined", + setup: func(t *testing.T) ([]string, []string, bool) { + dir := filepath.Join(t.TempDir(), "pages") + assert.NoError(t, mkdirall(dir)) + + solo := filepath.Join(t.TempDir(), "solo.md") + md := filepath.Join(dir, "a.md") + other := filepath.Join(dir, "b.txt") + + touch(t, solo) + touch(t, md) + touch(t, other) + + return []string{solo, dir}, []string{solo, md}, false + }, + }, + { + name: "nonexistent_aborts", + setup: func(t *testing.T) ([]string, []string, bool) { + dir := t.TempDir() + md := filepath.Join(dir, "a.md") + touch(t, md) + + return []string{md, filepath.Join(dir, "nope")}, nil, true + }, + }, + { + name: "empty_input", + setup: func(t *testing.T) ([]string, []string, bool) { + return nil, nil, false + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + paths, want, wantErr := tt.setup(t) + + got, err := collectFiles(paths) + + if wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got) + }, + ) + } +} + +func TestCollectPathFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T) (string, []string, bool) + }{ + { + name: "single_md_file", + setup: func(t *testing.T) (string, []string, bool) { + path := filepath.Join(t.TempDir(), "a.md") + touch(t, path) + return path, []string{path}, false + }, + }, + { + name: "non_md_direct_file_included", + setup: func(t *testing.T) (string, []string, bool) { + path := filepath.Join(t.TempDir(), "a.txt") + touch(t, path) + return path, []string{path}, false + }, + }, + { + name: "directory_walked_recursively", + setup: func(t *testing.T) (string, []string, bool) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + assert.NoError(t, mkdirall(sub)) + + files := []string{ + filepath.Join(dir, "a.md"), + filepath.Join(dir, "b.txt"), + filepath.Join(sub, "c.md"), + } + + for _, f := range files { + touch(t, f) + } + return dir, []string{files[0], files[2]}, false + }, + }, + { + name: "empty_directory", + setup: func(t *testing.T) (string, []string, bool) { + return t.TempDir(), nil, false + }, + }, + { + name: "nonexistent_path", + setup: func(t *testing.T) (string, []string, bool) { + return filepath.Join(t.TempDir(), "nope"), nil, true + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, want, wantErr := tt.setup(t) + + got, err := collectPathFiles(path) + + if wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got) + }) + } +} + +// touch creates an empty file at path with permissions 0600. +// It is intended for use in tests. +func touch(t *testing.T, path string) { + t.Helper() + assert.NoError(t, os.WriteFile(path, nil, 0o600)) +} + +// mkdirall creates path and any missing parent directories +// with permissions 0750. +func mkdirall(path string) error { + return os.MkdirAll(path, 0o750) +} diff --git a/internal/app/format.go b/internal/app/format.go new file mode 100644 index 0000000..4ff2bd3 --- /dev/null +++ b/internal/app/format.go @@ -0,0 +1,143 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/lint" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" +) + +// formatPages formats every page reachable from the CLI paths, +// writing each result to stdout, +// back to the source file, or to a single output file +// depending on the flags. +// +// It returns 0 when every page formatted without lint errors and 1 +// otherwise. +func (a *App) formatPages(cli *cmd.CLI) int { + files, err := collectFiles(cli.Page) + if err != nil { + logger.Error("%v", err) + return 1 + } + + if cli.Output != "" && len(files) != 1 { + logger.Error( + "flag %s requires a single file", + termcolor.Sprint("bold blue", "--output"), + ) + return 1 + } + + failed := false + for _, file := range files { + hasErrors, err := a.formatFile(file, cli) + if err != nil { + logger.Error("%v", err) + } + if hasErrors { + failed = true + } + } + + if failed { + return 1 + } + + return 0 +} + +// formatFile lints, reformats, +// and writes a single page, +// reporting whether the run failed. +func (a *App) formatFile( + file string, + cli *cmd.CLI, +) (bool, error) { + result, err := a.lintFile(file, cli.Ignore...) + if err != nil { + return true, err + } + + for _, e := range result.Errors { + a.writeLintError(file, e, cli.Tabular) + } + + root, err := os.OpenRoot(filepath.Dir(file)) + if err != nil { + return true, err + } + defer func() { + _ = root.Close() + }() + + content, err := root.ReadFile(filepath.Base(file)) + if err != nil { + return true, err + } + + formatted := lint.Format(string(content)) + if formatted == "" { + _, _ = fmt.Fprintln( + a.Stderr, + "refraining from formatting because of a fatal error", + ) + return true, nil + } + + err = a.writeFormatted(root, file, formatted, cli) + return len(result.Errors) > 0 || err != nil, err +} + +// writeFormatted emits the formatted page content +// according to the CLI flags. +// +// With --in-place it writes the content back over the source file +// with 0600 permissions, through the caller-supplied root +// for that file's directory. +// +// With --output it instead opens a root anchored at the output file's directory +// and writes filepath.Base of the output path into it, +// so the destination may live in a different directory +// from the source. +// +// With neither flag set it prints the content to a.Stdout +// followed by a newline, mirroring the reference linter's console output. +// +// It returns the underlying write error, if any. +func (a *App) writeFormatted( + root *os.Root, + path string, + content string, + cli *cmd.CLI, +) error { + switch { + case cli.InPlace: + return root.WriteFile( + filepath.Base(path), + []byte(content), + 0o600, + ) + case cli.Output != "": + outputRoot, err := os.OpenRoot(filepath.Dir(cli.Output)) + if err != nil { + return err + } + defer func() { + _ = outputRoot.Close() + }() + + return outputRoot.WriteFile( + filepath.Base(cli.Output), + []byte(content), + 0o600, + ) + default: + _, err := fmt.Fprint(a.Stdout, content, "\n") + return err + } +} diff --git a/internal/app/format_test.go b/internal/app/format_test.go new file mode 100644 index 0000000..566ce4a --- /dev/null +++ b/internal/app/format_test.go @@ -0,0 +1,142 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatPages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T) ([]string, *cmd.CLI) + wantCode int + wantStdout *string + wantStderr []string + check func(*testing.T, string, *cmd.CLI, string) + }{ + { + name: "valid_file_prints_to_stdout", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "tar.md", validPage) + return []string{path}, &cmd.CLI{} + }, + wantCode: 0, + wantStdout: new(validPage + "\n"), + }, + { + name: "invalid_file_still_formats_with_errors", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "bad.md", "x tar\n> no\n") + return []string{path}, &cmd.CLI{} + }, + wantCode: 1, + wantStderr: []string{"bad.md"}, + check: func(t *testing.T, _ string, _ *cmd.CLI, stdout string) { + assert.NotEmpty(t, stdout) + }, + }, + { + name: "in_place_rewrites_file", + wantStdout: new(""), + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "bad.md", "x tar\n> no\n") + return []string{path}, &cmd.CLI{InPlace: true} + }, + wantCode: 1, + check: func(t *testing.T, path string, _ *cmd.CLI, _ string) { + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotEqual(t, "x tar\n> no\n", string(got)) + assert.NotEmpty(t, string(got)) + }, + }, + { + name: "output_flag_writes_file", + wantStdout: new(""), + setup: func(t *testing.T) ([]string, *cmd.CLI) { + dir := t.TempDir() + path := writePage(t, dir, "tar.md", validPage) + return []string{path}, &cmd.CLI{Output: filepath.Join(dir, "out.md")} + }, + wantCode: 0, + check: func(t *testing.T, _ string, cli *cmd.CLI, _ string) { + got, err := os.ReadFile(cli.Output) + require.NoError(t, err) + assert.NotEmpty(t, string(got)) + }, + }, + { + name: "output_flag_to_different_directory", + wantStdout: new(""), + setup: func(t *testing.T) ([]string, *cmd.CLI) { + src := t.TempDir() + dst := filepath.Join(t.TempDir(), "nested") + require.NoError(t, os.MkdirAll(dst, 0o750)) + path := writePage(t, src, "tar.md", validPage) + return []string{path}, &cmd.CLI{Output: filepath.Join(dst, "out.md")} + }, + wantCode: 0, + check: func(t *testing.T, _ string, cli *cmd.CLI, _ string) { + got, err := os.ReadFile(cli.Output) + require.NoError(t, err) + assert.NotEmpty(t, string(got)) + }, + }, + { + name: "output_flag_requires_single_file", + wantStdout: new(""), + setup: func(t *testing.T) ([]string, *cmd.CLI) { + dir := t.TempDir() + p1 := writePage(t, dir, "a.md", validPage) + p2 := writePage(t, dir, "b.md", validPage) + return []string{p1, p2}, &cmd.CLI{Output: filepath.Join(dir, "out.md")} + }, + wantCode: 1, + }, + { + name: "unparseable_content_refrains", + wantStdout: new(""), + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "tar.md", " \n") + return []string{path}, &cmd.CLI{} + }, + wantCode: 1, + wantStderr: []string{"refraining from formatting"}, + check: func(t *testing.T, path string, _ *cmd.CLI, _ string) { + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, " \n", string(got)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, cli := tt.setup(t) + a, stdout, stderr := newTestApp() + cli.Page = paths + + got := a.formatPages(cli) + + assert.Equal(t, tt.wantCode, got) + if tt.wantStdout != nil { + assert.Equal(t, *tt.wantStdout, stdout.String()) + } + + for _, want := range tt.wantStderr { + assert.Contains(t, stderr.String(), want) + } + + if tt.check != nil { + tt.check(t, paths[0], cli, stdout.String()) + } + }) + } +} diff --git a/internal/app/lint.go b/internal/app/lint.go index 0fa24d7..2155938 100644 --- a/internal/app/lint.go +++ b/internal/app/lint.go @@ -2,7 +2,6 @@ package app import ( "fmt" - "io/fs" "os" "path/filepath" @@ -11,10 +10,12 @@ import ( "github.com/TheRootDaemon/tlgc/logger" ) -// lintPages validates the tldr pages -// under the given paths -// and reports any lint errors to stderr. -// Returns 0 when no errors are found, 1 otherwise. +// lintPages runs the linter over every page reachable +// from the CLI paths and reports each violation to stderr. +// +// It returns 0 when every file passed without violations +// and 1 otherwise; +// a failure to collect or open any file is logged and also yields 1. func (a *App) lintPages(cli *cmd.CLI) int { files, err := collectFiles(cli.Page) if err != nil { @@ -22,26 +23,9 @@ func (a *App) lintPages(cli *cmd.CLI) int { return 1 } - root, err := os.OpenRoot(".") - if err != nil { - logger.Error("%v", err) - return 1 - } - defer func() { - _ = root.Close() - }() - failed := false for _, file := range files { - f, err := root.Open(file) - if err != nil { - logger.Error("%v", err) - failed = true - continue - } - - result, err := lint.Lint(f, cli.Ignore...) - _ = f.Close() + result, err := a.lintFile(file, cli.Ignore...) if err != nil { logger.Error("%v", err) failed = true @@ -61,8 +45,36 @@ func (a *App) lintPages(cli *cmd.CLI) int { return 0 } -// writeLintError reports a single lint error to stderr -// in the default or tabular reference format. +// lintFile opens the page at path and runs every applicable lint rule +// over it, returning the violations found. +// +// Error codes passed in ignore are suppressed by the linter. +// +// On any open or lint failure it returns an empty Result together with the error. +func (a *App) lintFile( + path string, + ignore ...string, +) (*lint.Result, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return &lint.Result{}, err + } + defer func() { + _ = root.Close() + }() + + f, err := root.Open(filepath.Base(path)) + if err != nil { + return &lint.Result{}, err + } + defer func() { + _ = f.Close() + }() + + return lint.Lint(f, ignore...) +} + +// writeLintError reports a single lint violation for path to a.Stderr. func (a *App) writeLintError( path string, e lint.Error, @@ -89,60 +101,3 @@ func (a *App) writeLintError( e.Description, ) } - -// collectFiles expands each input path -// into a flat list of page files. -// Individual .md files are included as-is, -// while directories are walked recursively -// to collect .md files. -func collectFiles(paths []string) ([]string, error) { - var files []string - - for _, path := range paths { - pathFiles, err := collectPathFiles(path) - if err != nil { - return nil, err - } - files = append(files, pathFiles...) - } - - return files, nil -} - -// collectPathFiles expands a single path into page files. -// A .md file is returned as-is, -// a directory is walked recursively for .md files, -// and other file types are ignored. -func collectPathFiles(path string) ([]string, error) { - info, err := os.Stat(path) - if err != nil { - return nil, err - } - - if !info.IsDir() { - if filepath.Ext(path) == ".md" { - return []string{path}, nil - } - return nil, nil - } - - var files []string - if err = filepath.WalkDir( - path, - func(entry string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if !d.IsDir() && filepath.Ext(entry) == ".md" { - files = append(files, entry) - } - - return nil - }, - ); err != nil { - return nil, err - } - - return files, nil -} diff --git a/internal/app/lint_test.go b/internal/app/lint_test.go index 7043452..eac5688 100644 --- a/internal/app/lint_test.go +++ b/internal/app/lint_test.go @@ -1,205 +1,93 @@ package app import ( - "bytes" - "os" "path/filepath" - "sort" + "strings" "testing" - "github.com/TheRootDaemon/tlgc/internal/lint" + "github.com/TheRootDaemon/tlgc/cmd" "github.com/stretchr/testify/assert" ) -func TestCollectFiles(t *testing.T) { +func TestLintPages(t *testing.T) { t.Parallel() tests := []struct { - name string - setup func(*testing.T) ([]string, []string, bool) + name string + setup func(*testing.T) ([]string, *cmd.CLI) + wantCode int + wantStderr []string }{ { - name: "file_and_directory_combined", - setup: func(t *testing.T) ([]string, []string, bool) { - dir := filepath.Join(t.TempDir(), "pages") - assert.NoError(t, mkdirall(dir)) - - solo := filepath.Join(t.TempDir(), "solo.md") - md := filepath.Join(dir, "a.md") - other := filepath.Join(dir, "b.txt") - - touch(t, solo) - touch(t, md) - touch(t, other) - - return []string{solo, dir}, []string{solo, md}, false + name: "valid_file_returns_zero", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "tar.md", validPage) + return []string{path}, &cmd.CLI{} }, + wantCode: 0, }, { - name: "nonexistent_aborts", - setup: func(t *testing.T) ([]string, []string, bool) { - dir := t.TempDir() - md := filepath.Join(dir, "a.md") - touch(t, md) - - return []string{md, filepath.Join(dir, "nope")}, nil, true - }, - }, - { - name: "empty_input", - setup: func(t *testing.T) ([]string, []string, bool) { - return nil, nil, false - }, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - paths, want, wantErr := tt.setup(t) - - got, err := collectFiles(paths) - - if wantErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - sort.Strings(got) - sort.Strings(want) - assert.Equal(t, want, got) - }, - ) - } -} - -func TestCollectPathFiles(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - setup func(*testing.T) (string, []string, bool) - }{ - { - name: "single_md_file", - setup: func(t *testing.T) (string, []string, bool) { - path := filepath.Join(t.TempDir(), "a.md") - touch(t, path) - return path, []string{path}, false + name: "invalid_file_reports_to_stderr", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "bad.md", "x tar\n> no\n") + return []string{path}, &cmd.CLI{} }, + wantCode: 1, + wantStderr: []string{"bad.md"}, }, { - name: "non_md_file_ignored", - setup: func(t *testing.T) (string, []string, bool) { - path := filepath.Join(t.TempDir(), "a.txt") - touch(t, path) - return path, nil, false + name: "directory_walk_skips_non_md", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + dir := t.TempDir() + writePage(t, dir, "bad.txt", "x tar\n> no\n") + writePage(t, dir, "good.md", validPage) + return []string{dir}, &cmd.CLI{} }, + wantCode: 0, }, { - name: "directory_walked_recursively", - setup: func(t *testing.T) (string, []string, bool) { - dir := t.TempDir() - sub := filepath.Join(dir, "sub") - assert.NoError(t, mkdirall(sub)) - - files := []string{ - filepath.Join(dir, "a.md"), - filepath.Join(dir, "b.txt"), - filepath.Join(sub, "c.md"), - } - - for _, f := range files { - touch(t, f) - } - return dir, []string{files[0], files[2]}, false + name: "non_md_direct_file_still_linted", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "page.txt", validPage) + return []string{path}, &cmd.CLI{} }, + wantCode: 1, + wantStderr: []string{"page.txt"}, }, { - name: "empty_directory", - setup: func(t *testing.T) (string, []string, bool) { - return t.TempDir(), nil, false + name: "ignore_codes_suppress_errors", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + path := writePage(t, t.TempDir(), "bad.md", "x tar\n> no\n") + return []string{path}, &cmd.CLI{Ignore: []string{"TLDR106"}} }, + wantCode: 0, }, { - name: "nonexistent_path", - setup: func(t *testing.T) (string, []string, bool) { - return filepath.Join(t.TempDir(), "nope"), nil, true + name: "nonexistent_path_returns_one", + setup: func(t *testing.T) ([]string, *cmd.CLI) { + return []string{filepath.Join(t.TempDir(), "nope")}, &cmd.CLI{} }, + wantCode: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, want, wantErr := tt.setup(t) + paths, cli := tt.setup(t) + a, _, stderr := newTestApp() + cli.Page = paths - got, err := collectPathFiles(path) + got := a.lintPages(cli) - if wantErr { - assert.Error(t, err) - return + assert.Equal(t, tt.wantCode, got) + out := stderr.String() + for _, want := range tt.wantStderr { + assert.Contains(t, out, want) } - assert.NoError(t, err) - sort.Strings(got) - sort.Strings(want) - assert.Equal(t, want, got) + if len(tt.wantStderr) == 0 { + assert.Empty(t, out, strings.TrimSpace(out)) + } }) } } - -func TestWriteLintError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - path string - err lint.Error - tabular bool - want string - }{ - { - name: "plain_format", - path: "pages/a.md", - err: lint.Error{Code: "TLDR001", Line: 1, Description: "leading whitespace"}, - want: "pages/a.md:1: TLDR001 leading whitespace\n", - }, - { - name: "tabular_format", - path: "pages/a.md", - err: lint.Error{Code: "TLDR001", Line: 1, Description: "leading whitespace"}, - tabular: true, - want: "pages/a.md\t1\tTLDR001\tleading whitespace\t\n", - }, - } - - for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - var buf bytes.Buffer - a := &App{ - Stderr: &buf, - } - - a.writeLintError(tt.path, tt.err, tt.tabular) - assert.Equal(t, tt.want, buf.String()) - }, - ) - } -} - -// touch creates an empty file at path with permissions 0600. -// It is intended for use in tests. -func touch(t *testing.T, path string) { - t.Helper() - assert.NoError(t, os.WriteFile(path, nil, 0o600)) -} - -// mkdirall creates path and any missing parent directories -// with permissions 0750. -func mkdirall(path string) error { - return os.MkdirAll(path, 0o750) -} diff --git a/internal/app/main_test.go b/internal/app/main_test.go new file mode 100644 index 0000000..988850b --- /dev/null +++ b/internal/app/main_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/TheRootDaemon/tlgc/logger" + "github.com/stretchr/testify/require" +) + +// validPage is a valid tldr page used by app tests. +const validPage = "# tar\n\n> Archiving utility.\n\n- Create an archive:\n\n`tar cf archive.tar`\n" + +// writePage writes content to a file in dir and returns its path. +func writePage(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// newTestApp creates an App with buffers +// for capturing standard output and standard error. +func newTestApp() (*App, *bytes.Buffer, *bytes.Buffer) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + app := &App{ + Stdout: stdout, + Stderr: stderr, + } + + return app, stdout, stderr +} + +func TestMain(m *testing.M) { + logger.SetDefault(logger.NewWithWriter(true, 0, io.Discard)) + os.Exit(m.Run()) +} From e1b8d573e8d823d5766c9b44e648a71fbcbb8aec Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 14 Aug 2026 13:33:16 +0530 Subject: [PATCH 52/58] fix(lint): 1-indexed linting --- internal/lint/file_rules.go | 24 ++- internal/lint/file_rules_test.go | 235 ++++++++++++++++++------------ internal/lint/lint_test.go | 70 ++++----- internal/lint/main_test.go | 14 ++ internal/lint/parse.go | 6 +- internal/lint/parse_lines.go | 4 +- internal/lint/parse_lines_test.go | 28 ++-- internal/lint/parse_test.go | 68 ++++----- internal/lint/title_rules.go | 9 +- internal/lint/title_rules_test.go | 73 ++++------ 10 files changed, 290 insertions(+), 241 deletions(-) diff --git a/internal/lint/file_rules.go b/internal/lint/file_rules.go index fa4cd6a..87592f5 100644 --- a/internal/lint/file_rules.go +++ b/internal/lint/file_rules.go @@ -19,8 +19,9 @@ func checkLeadingWhitespace(p *parsedPage, r *Result) { (l.rawLine[0] == ' ' || l.rawLine[0] == '\t') { addError(r, "TLDR001", l.lineNumber) } else if i > 0 { - // leading blank line triggers TLDR001. - addError(r, "TLDR001", 0) + // leading blank line triggers TLDR001 + // at the start of the leading-whitespace region. + addError(r, "TLDR001", p.lines[0].lineNumber) } break @@ -79,17 +80,20 @@ func checkNoTrailingWhitespaceAtEOF(p *parsedPage, r *Result) { // It reports an error if the page does not end with a newline. func checkEndsWithNewline(p *parsedPage, r *Result) { if !strings.HasSuffix(p.rawContent, "\n") { - addError(r, "TLDR009", 0) + addError(r, "TLDR009", 1) } } // checkUnixLineEndings enforces TLDR010. // -// It reports an error if the page contains carriage returns, -// that is, non-Unix (CRLF or CR) line endings. +// It reports an error for every line +// that contains a carriage return, +// that is, a non-Unix (CRLF or CR) line ending. func checkUnixLineEndings(p *parsedPage, r *Result) { - if strings.Contains(p.rawContent, "\r") { - addError(r, "TLDR010", 0) + for _, l := range p.lines { + if strings.Contains(l.rawLine, "\r") { + addError(r, "TLDR010", l.lineNumber) + } } } @@ -100,14 +104,18 @@ func checkUnixLineEndings(p *parsedPage, r *Result) { // A trailing run at EOF is consumed by TLDR008's // whitespace-at-end-of-file rule and is not reported here. func checkConsecutiveBlankLines(p *parsedPage, r *Result) { + runStart := 0 run := 0 for _, l := range p.lines { if l.kind == kindBlank { + if run == 0 { + runStart = l.lineNumber + } run++ continue } if run > 1 { - addError(r, "TLDR011", l.lineNumber) + addError(r, "TLDR011", runStart) } run = 0 } diff --git a/internal/lint/file_rules_test.go b/internal/lint/file_rules_test.go index 0a0ef0c..ea917f6 100644 --- a/internal/lint/file_rules_test.go +++ b/internal/lint/file_rules_test.go @@ -7,10 +7,29 @@ import ( ) func TestCheckLeadingWhitespace(t *testing.T) { - blank := parsedLine{kind: kindBlank, lineNumber: 0, rawLine: ""} - title := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "# App", content: "App"} - leadingSpace := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: " # App", content: "App"} - leadingTab := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "\t# App", content: "App"} + blank := parsedLine{ + kind: kindBlank, + lineNumber: 0, + rawLine: "", + } + title := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: "# App", + content: "App", + } + leadingSpace := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: " # App", + content: "App", + } + leadingTab := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: "\t# App", + content: "App", + } tests := []struct { name string @@ -23,26 +42,60 @@ func TestCheckLeadingWhitespace(t *testing.T) { {name: "leading blank line fails", lines: []parsedLine{blank, title}, wantCode: "TLDR001"}, {name: "empty page passes", lines: nil, wantCode: ""}, } + for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkLeadingWhitespace(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkLeadingWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } func TestCheckSpaceAfterPrefix(t *testing.T) { - title := parsedLine{kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"} - noSpaceTitle := parsedLine{kind: kindTitle, lineNumber: 1, rawLine: "#App", content: "App"} - description := parsedLine{kind: kindDescription, lineNumber: 2, rawLine: "> Description.", content: "Description."} - noSpaceDescription := parsedLine{kind: kindDescription, lineNumber: 3, rawLine: ">Description.", content: "Description."} - exampleDescription := parsedLine{kind: kindExampleDesc, lineNumber: 4, rawLine: "- Example:", content: "Example:"} - noSpaceExampleDescription := parsedLine{kind: kindExampleDesc, lineNumber: 5, rawLine: "-Example:", content: "Example:"} - command := parsedLine{kind: kindCommand, lineNumber: 6, rawLine: "`ls`", content: "ls", hasClosingBacktick: true} + title := parsedLine{ + kind: kindTitle, + lineNumber: 0, + rawLine: "# App", + content: "App", + } + noSpaceTitle := parsedLine{ + kind: kindTitle, + lineNumber: 1, + rawLine: "#App", + content: "App", + } + description := parsedLine{ + kind: kindDescription, + lineNumber: 2, + rawLine: "> Description.", + content: "Description.", + } + noSpaceDescription := parsedLine{ + kind: kindDescription, + lineNumber: 3, + rawLine: ">Description.", + content: "Description.", + } + exampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 4, + rawLine: "- Example:", + content: "Example:", + } + noSpaceExampleDescription := parsedLine{ + kind: kindExampleDesc, + lineNumber: 5, + rawLine: "-Example:", + content: "Example:", + } + command := parsedLine{ + kind: kindCommand, + lineNumber: 6, + rawLine: "`ls`", + content: "ls", + hasClosingBacktick: true, + } tests := []struct { name string @@ -76,14 +129,11 @@ func TestCheckSpaceAfterPrefix(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkSpaceAfterPrefix(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkSpaceAfterPrefix(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -99,29 +149,26 @@ func TestCheckNoTrailingWhitespaceAtEOF(t *testing.T) { {name: "trailing space on last line passes", raw: "# App ", wantCodes: nil}, {name: "spaces only no newline passes", raw: "# App ", wantCodes: nil}, {name: "trailing space then newline passes", raw: "# App \n", wantCodes: nil}, - {name: "one blank line at EOF fails", raw: "# App\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "multiple blank lines at EOF fail", raw: "# App\n\n\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "blank line with space at EOF fails", raw: "# App\n \n", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "blank line then trailing space fails", raw: "# App\n\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "whitespace after final newline fails", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "tab after final newline fails", raw: "# App\n\t", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "spaces after final newline fail", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "crlf blank line at EOF fails", raw: "# App\n\r\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, - {name: "crlf then newline at EOF fails", raw: "# App\r\n\n", wantCodes: []string{"TLDR008"}, wantLine: 1}, + {name: "one blank line at EOF fails", raw: "# App\n\n", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "multiple blank lines at EOF fail", raw: "# App\n\n\n\n", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "blank line with space at EOF fails", raw: "# App\n \n", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "blank line then trailing space fails", raw: "# App\n\n ", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "whitespace after final newline fails", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "tab after final newline fails", raw: "# App\n\t", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "spaces after final newline fail", raw: "# App\n ", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "crlf blank line at EOF fails", raw: "# App\n\r\n", wantCodes: []string{"TLDR008"}, wantLine: 2}, + {name: "crlf then newline at EOF fails", raw: "# App\r\n\n", wantCodes: []string{"TLDR008"}, wantLine: 2}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - lines := parseLines(tt.raw) - checkNoTrailingWhitespaceAtEOF(&parsedPage{rawContent: tt.raw, lines: lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - if len(r.Errors) > 0 { - require.Equal(t, tt.wantLine, r.Errors[0].Line) - } - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + lines := parseLines(tt.raw) + checkNoTrailingWhitespaceAtEOF(&parsedPage{rawContent: tt.raw, lines: lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + if len(r.Errors) > 0 { + require.Equal(t, tt.wantLine, r.Errors[0].Line) + } + }) } } @@ -136,35 +183,46 @@ func TestCheckEndsWithNewline(t *testing.T) { {name: "empty page fails", raw: "", wantCode: "TLDR009"}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkEndsWithNewline(&parsedPage{rawContent: tt.raw}, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkEndsWithNewline(&parsedPage{rawContent: tt.raw}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } func TestCheckUnixLineEndings(t *testing.T) { tests := []struct { - name string - raw string - wantCode string + name string + raw string + wantCodes []string + wantLines []int }{ - {name: "unix line endings pass", raw: "# App\n", wantCode: ""}, - {name: "carriage return fails", raw: "# App\r\n", wantCode: "TLDR010"}, + { + name: "unix line endings pass", + raw: "# App\n", + wantCodes: nil, + }, + { + name: "carriage return fails", + raw: "# App\r\n", + wantCodes: []string{"TLDR010"}, + wantLines: []int{1}, + }, + { + name: "crlf on multiple lines fails per line", + raw: "# App\r\n> Description.\r\n", + wantCodes: []string{"TLDR010", "TLDR010"}, + wantLines: []int{1, 2}, + }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkUnixLineEndings(&parsedPage{rawContent: tt.raw}, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkUnixLineEndings(&parsedPage{rawContent: tt.raw, lines: parseLines(tt.raw)}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + require.Equal(t, tt.wantLines, errorLines(r)) + }) } } @@ -202,14 +260,11 @@ func TestCheckConsecutiveBlankLines(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkConsecutiveBlankLines(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkConsecutiveBlankLines(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -223,14 +278,11 @@ func TestCheckNoTabs(t *testing.T) { {name: "tab fails", raw: "# App\t\n", wantCode: "TLDR012"}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkNoTabs(&parsedPage{rawContent: tt.raw, lines: parseLines(tt.raw)}, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkNoTabs(&parsedPage{rawContent: tt.raw, lines: parseLines(tt.raw)}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -254,13 +306,10 @@ func TestCheckTrailingWhitespace(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkTrailingWhitespace(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkTrailingWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } diff --git a/internal/lint/lint_test.go b/internal/lint/lint_test.go index 9e434c9..d32c68f 100644 --- a/internal/lint/lint_test.go +++ b/internal/lint/lint_test.go @@ -28,7 +28,7 @@ func TestLintSpecsFailing(t *testing.T) { {"failing/007.md", []string{"TLDR007"}, 2, false}, {"failing/008.md", []string{"TLDR008"}, 1, false}, {"failing/009.md", []string{"TLDR009"}, 1, false}, - {"failing/010.md", []string{"TLDR010"}, 1, false}, + {"failing/010.md", []string{"TLDR010"}, 7, false}, {"failing/011.md", []string{"TLDR011"}, 2, false}, {"failing/012.md", []string{"TLDR012"}, 2, false}, {"failing/013.md", []string{"TLDR013"}, 1, false}, @@ -53,20 +53,17 @@ func TestLintSpecsFailing(t *testing.T) { {"failing/112.md", []string{"TLDR112"}, 7, false}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - f, err := os.Open(filepath.Join("specs", "pages", tt.name)) - require.NoError(t, err) - defer func() { - _ = f.Close() - }() + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(filepath.Join("specs", "pages", tt.name)) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() - r, err := Lint(f) - require.NoError(t, err) - assertSpecErrors(t, r, tt.want, tt.count, tt.subset) - }, - ) + r, err := Lint(f) + require.NoError(t, err) + assertSpecErrors(t, r, tt.want, tt.count, tt.subset) + }) } } @@ -75,13 +72,10 @@ func TestLintSpecsForbiddenFilenameCharacters(t *testing.T) { require.NoError(t, err) for _, char := range `<>:"/\|?*` { - t.Run( - "111"+string(char), - func(t *testing.T) { - r := lint("111"+string(char)+".md", content) - assertSpecErrors(t, r, []string{"TLDR111"}, 1, false) - }, - ) + t.Run("111"+string(char), func(t *testing.T) { + r := lint("111"+string(char)+".md", content) + assertSpecErrors(t, r, []string{"TLDR111"}, 1, false) + }) } } @@ -89,20 +83,17 @@ func TestLintSpecsPassing(t *testing.T) { entries, err := os.ReadDir(filepath.Join("specs", "pages", "passing")) require.NoError(t, err) for _, entry := range entries { - t.Run( - entry.Name(), - func(t *testing.T) { - f, err := os.Open(filepath.Join("specs", "pages", "passing", entry.Name())) - require.NoError(t, err) - defer func() { - _ = f.Close() - }() + t.Run(entry.Name(), func(t *testing.T) { + f, err := os.Open(filepath.Join("specs", "pages", "passing", entry.Name())) + require.NoError(t, err) + defer func() { + _ = f.Close() + }() - r, err := Lint(f) - require.NoError(t, err) - assertSpecErrors(t, r, nil, 0, false) - }, - ) + r, err := Lint(f) + require.NoError(t, err) + assertSpecErrors(t, r, nil, 0, false) + }) } } @@ -159,13 +150,10 @@ func TestString(t *testing.T) { } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := tt.err.String() - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := tt.err.String() + require.Equal(t, tt.want, got) + }) } } diff --git a/internal/lint/main_test.go b/internal/lint/main_test.go index 281731b..3799f2d 100644 --- a/internal/lint/main_test.go +++ b/internal/lint/main_test.go @@ -23,3 +23,17 @@ func errorCodes(r *Result) []string { } return codes } + +// errorLines returns the line numbers of all reported errors, +// in report order. +// It returns nil if the result is clean. +func errorLines(r *Result) []int { + if len(r.Errors) == 0 { + return nil + } + lines := make([]int, len(r.Errors)) + for i, e := range r.Errors { + lines[i] = e.Line + } + return lines +} diff --git a/internal/lint/parse.go b/internal/lint/parse.go index 51ac644..39e5b1b 100644 --- a/internal/lint/parse.go +++ b/internal/lint/parse.go @@ -17,7 +17,7 @@ type parsedLine struct { // kindBlank, kindTitle, kindDescription, kindExampleDesc, kindCommand, or kindText kind lineKind - // 0-indexed line number within the raw content + // 1-indexed line number within the raw content lineNumber int // original line text (without trailing \n) @@ -35,7 +35,7 @@ type commandSection struct { // example description text (after "- ") description string - // 0-indexed line number of the description + // 1-indexed line number of the description descriptionLineNumber int // command lines that belong to the example @@ -53,7 +53,7 @@ type parsedPage struct { // first title text (without the leading '#') title string - // 0-indexed line number of the title + // 1-indexed line number of the title titleLineNumber int // consecutive description lines following the title diff --git a/internal/lint/parse_lines.go b/internal/lint/parse_lines.go index cb9fb63..0551abe 100644 --- a/internal/lint/parse_lines.go +++ b/internal/lint/parse_lines.go @@ -13,10 +13,10 @@ func parseLines(raw string) []parsedLine { parts := strings.Split(raw, "\n") lines := make([]parsedLine, 0, len(parts)) - for lineNumber, part := range parts { + for idx, part := range parts { lines = append( lines, - parseLine(lineNumber, part), + parseLine(idx+1, part), ) } diff --git a/internal/lint/parse_lines_test.go b/internal/lint/parse_lines_test.go index 680d0cb..a2fb6a4 100644 --- a/internal/lint/parse_lines_test.go +++ b/internal/lint/parse_lines_test.go @@ -26,50 +26,50 @@ func TestParseLines(t *testing.T) { name: "single line", raw: "> Hello", want: []parsedLine{ - {kind: kindDescription, lineNumber: 0, rawLine: "> Hello", content: "Hello"}, + {kind: kindDescription, lineNumber: 1, rawLine: "> Hello", content: "Hello"}, }, }, { name: "consecutive lines are numbered in order", raw: "# App\n> D\n`c`", want: []parsedLine{ - {kind: kindTitle, lineNumber: 0, rawLine: "# App", content: "App"}, - {kind: kindDescription, lineNumber: 1, rawLine: "> D", content: "D"}, - {kind: kindCommand, lineNumber: 2, rawLine: "`c`", content: "c", hasClosingBacktick: true}, + {kind: kindTitle, lineNumber: 1, rawLine: "# App", content: "App"}, + {kind: kindDescription, lineNumber: 2, rawLine: "> D", content: "D"}, + {kind: kindCommand, lineNumber: 3, rawLine: "`c`", content: "c", hasClosingBacktick: true}, }, }, { name: "trailing newline yields a trailing blank line", raw: "> A\n", want: []parsedLine{ - {kind: kindDescription, lineNumber: 0, rawLine: "> A", content: "A"}, - {kind: kindBlank, lineNumber: 1, rawLine: ""}, + {kind: kindDescription, lineNumber: 1, rawLine: "> A", content: "A"}, + {kind: kindBlank, lineNumber: 2, rawLine: ""}, }, }, { name: "crlf endings classify correctly but keep raw", raw: "> A\r\n`B`\r\n", want: []parsedLine{ - {kind: kindDescription, lineNumber: 0, rawLine: "> A\r", content: "A"}, - {kind: kindCommand, lineNumber: 1, rawLine: "`B`\r", content: "B", hasClosingBacktick: true}, - {kind: kindBlank, lineNumber: 2, rawLine: ""}, + {kind: kindDescription, lineNumber: 1, rawLine: "> A\r", content: "A"}, + {kind: kindCommand, lineNumber: 2, rawLine: "`B`\r", content: "B", hasClosingBacktick: true}, + {kind: kindBlank, lineNumber: 3, rawLine: ""}, }, }, { name: "leading blank line is preserved", raw: "\n# T", want: []parsedLine{ - {kind: kindBlank, lineNumber: 0, rawLine: ""}, - {kind: kindTitle, lineNumber: 1, rawLine: "# T", content: "T"}, + {kind: kindBlank, lineNumber: 1, rawLine: ""}, + {kind: kindTitle, lineNumber: 2, rawLine: "# T", content: "T"}, }, }, { name: "empty middle line is a blank", raw: "# T\n\n> D", want: []parsedLine{ - {kind: kindTitle, lineNumber: 0, rawLine: "# T", content: "T"}, - {kind: kindBlank, lineNumber: 1, rawLine: ""}, - {kind: kindDescription, lineNumber: 2, rawLine: "> D", content: "D"}, + {kind: kindTitle, lineNumber: 1, rawLine: "# T", content: "T"}, + {kind: kindBlank, lineNumber: 2, rawLine: ""}, + {kind: kindDescription, lineNumber: 3, rawLine: "> D", content: "D"}, }, }, } diff --git a/internal/lint/parse_test.go b/internal/lint/parse_test.go index 99daea0..56d0c02 100644 --- a/internal/lint/parse_test.go +++ b/internal/lint/parse_test.go @@ -11,68 +11,68 @@ func TestParse(t *testing.T) { title := parsedLine{ kind: kindTitle, - lineNumber: 0, + lineNumber: 1, rawLine: "# App", content: "App", } blank_1 := parsedLine{ kind: kindBlank, - lineNumber: 1, + lineNumber: 2, rawLine: "", } desc := parsedLine{ kind: kindDescription, - lineNumber: 2, + lineNumber: 3, rawLine: "> Brief description.", content: "Brief description.", } link := parsedLine{ kind: kindDescription, - lineNumber: 3, + lineNumber: 4, rawLine: "> More information: https://example.com", content: "More information: https://example.com", } blank_4 := parsedLine{ kind: kindBlank, - lineNumber: 4, + lineNumber: 5, rawLine: "", } copyDescription := parsedLine{ kind: kindExampleDesc, - lineNumber: 5, + lineNumber: 6, rawLine: "- Copy files", content: "Copy files", } blank_6 := parsedLine{ kind: kindBlank, - lineNumber: 6, + lineNumber: 7, rawLine: "", } copyCommand := parsedLine{ kind: kindCommand, - lineNumber: 7, + lineNumber: 8, rawLine: "`cp file file.bak`", content: "cp file file.bak", hasClosingBacktick: true, } blank_8 := parsedLine{ kind: kindBlank, - lineNumber: 8, + lineNumber: 9, rawLine: "", } backupDescription := parsedLine{ kind: kindExampleDesc, - lineNumber: 9, + lineNumber: 10, rawLine: "- Create a backup", content: "Create a backup", } blank_10 := parsedLine{ kind: kindBlank, - lineNumber: 10, + lineNumber: 11, rawLine: "", } backupCommand := parsedLine{ kind: kindCommand, - lineNumber: 11, + lineNumber: 12, rawLine: "`tar czf backup.tar.gz file`", content: "tar czf backup.tar.gz file", hasClosingBacktick: true, @@ -99,8 +99,8 @@ func TestParse(t *testing.T) { want: &parsedPage{ rawContent: "some text\n", lines: []parsedLine{ - {kind: kindText, lineNumber: 0, rawLine: "some text", content: "some text"}, - {kind: kindBlank, lineNumber: 1, rawLine: ""}, // trailing newline splits into a blank line + {kind: kindText, lineNumber: 1, rawLine: "some text", content: "some text"}, + {kind: kindBlank, lineNumber: 2, rawLine: ""}, // trailing newline splits into a blank line }, }, }, @@ -111,12 +111,12 @@ func TestParse(t *testing.T) { rawContent: raw, lines: []parsedLine{title, blank_1, desc, link, blank_4, copyDescription, blank_6, copyCommand, blank_8, backupDescription, blank_10, backupCommand}, title: "App", - titleLineNumber: 0, + titleLineNumber: 1, descriptions: []parsedLine{desc, link}, infoLinks: []parsedLine{link}, exampleSections: []commandSection{ - {description: "Copy files", descriptionLineNumber: 5, commands: []parsedLine{copyCommand}}, - {description: "Create a backup", descriptionLineNumber: 9, commands: []parsedLine{backupCommand}}, + {description: "Copy files", descriptionLineNumber: 6, commands: []parsedLine{copyCommand}}, + {description: "Create a backup", descriptionLineNumber: 10, commands: []parsedLine{backupCommand}}, }, }, }, @@ -126,27 +126,24 @@ func TestParse(t *testing.T) { want: &parsedPage{ rawContent: "# T\r\n\r\n> D\r\n", lines: []parsedLine{ - {kind: kindTitle, lineNumber: 0, rawLine: "# T\r", content: "T"}, - {kind: kindBlank, lineNumber: 1, rawLine: "\r"}, - {kind: kindDescription, lineNumber: 2, rawLine: "> D\r", content: "D"}, - {kind: kindBlank, lineNumber: 3, rawLine: ""}, + {kind: kindTitle, lineNumber: 1, rawLine: "# T\r", content: "T"}, + {kind: kindBlank, lineNumber: 2, rawLine: "\r"}, + {kind: kindDescription, lineNumber: 3, rawLine: "> D\r", content: "D"}, + {kind: kindBlank, lineNumber: 4, rawLine: ""}, }, title: "T", - titleLineNumber: 0, - descriptions: []parsedLine{{kind: kindDescription, lineNumber: 2, rawLine: "> D\r", content: "D"}}, + titleLineNumber: 1, + descriptions: []parsedLine{{kind: kindDescription, lineNumber: 3, rawLine: "> D\r", content: "D"}}, infoLinks: []parsedLine{}, exampleSections: nil, }, }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := parse(tt.raw) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := parse(tt.raw) + require.Equal(t, tt.want, got) + }) } } @@ -297,12 +294,9 @@ func TestBuildPage(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := buildPage(tt.raw, tt.lines) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := buildPage(tt.raw, tt.lines) + require.Equal(t, tt.want, got) + }) } } diff --git a/internal/lint/title_rules.go b/internal/lint/title_rules.go index 38c0647..e9b809e 100644 --- a/internal/lint/title_rules.go +++ b/internal/lint/title_rules.go @@ -70,13 +70,18 @@ func checkTitleCharacters(p *parsedPage, r *Result) { // It reports an error if the page does not contain a title line, // that is, a line beginning with '#'. func checkTitleHash(p *parsedPage, r *Result) { - // if the page has no title, error at line 0. + // if the page has no title, error at the first non-blank line. for _, l := range p.lines { if l.kind == kindTitle { return } } - addError(r, "TLDR106", 0) + for _, l := range p.lines { + if l.kind != kindBlank { + addError(r, "TLDR106", l.lineNumber) + return + } + } } // isValidTitleRune reports whether ch is permitted in a page title. diff --git a/internal/lint/title_rules_test.go b/internal/lint/title_rules_test.go index ae051ad..c89c540 100644 --- a/internal/lint/title_rules_test.go +++ b/internal/lint/title_rules_test.go @@ -92,21 +92,18 @@ func TestCheckTitleDescriptionSeparator(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkTitleDescriptionSeparator( - &parsedPage{ - lines: tt.lines, - descriptions: tt.descriptions, - titleLineNumber: tt.titleLineNumber, - }, - r, - ) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkTitleDescriptionSeparator( + &parsedPage{ + lines: tt.lines, + descriptions: tt.descriptions, + titleLineNumber: tt.titleLineNumber, + }, + r, + ) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -127,20 +124,17 @@ func TestCheckTitleCharacters(t *testing.T) { {name: "trailing period fails", title: "App.", titleLineNumber: 2, wantCode: "TLDR013"}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkTitleCharacters( - &parsedPage{ - title: tt.title, - titleLineNumber: tt.titleLineNumber, - }, - r, - ) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkTitleCharacters( + &parsedPage{ + title: tt.title, + titleLineNumber: tt.titleLineNumber, + }, + r, + ) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -157,7 +151,7 @@ func TestCheckTitleHash(t *testing.T) { rawLine: "> D", content: "D", } - ccommand := parsedLine{ + command := parsedLine{ kind: kindCommand, lineNumber: 1, rawLine: "`c`", @@ -177,18 +171,18 @@ func TestCheckTitleHash(t *testing.T) { }, { name: "title in the middle passes", - lines: []parsedLine{description, ccommand, title}, + lines: []parsedLine{description, command, title}, wantCode: "", }, { name: "no title fails", - lines: []parsedLine{description, ccommand}, + lines: []parsedLine{description, command}, wantCode: "TLDR106", }, { - name: "empty page fails", + name: "empty page yields no error", lines: nil, - wantCode: "TLDR106", + wantCode: "", }, } for _, tt := range tests { @@ -222,12 +216,9 @@ func TestIsValidTitleRune(t *testing.T) { {name: "slash", in: '/', want: false}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := isValidTitleRune(tt.in) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := isValidTitleRune(tt.in) + require.Equal(t, tt.want, got) + }) } } From b11e343f0d5e3f2759e2f0d3e26de17f392e0351 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 14 Aug 2026 13:38:32 +0530 Subject: [PATCH 53/58] chore: Add help strings, completions for --format --- cmd/help.go | 14 ++++++++++++++ completions/_tldr | 3 +++ completions/tldr.bash | 5 +++-- completions/tldr.fish | 3 +++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cmd/help.go b/cmd/help.go index b9d56e8..63d8807 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -71,6 +71,20 @@ func printFlags() { arg: "", description: "Ignore comma-separated lint error codes", }, + { + long: "--format", + arg: "", + description: "Format the specified tldr pages", + }, + { + long: "--output", + arg: "", + description: "Write formatted output to the specified file", + }, + { + long: "--in-place", + description: "Format pages in place", + }, { short: "-s", long: "--search", diff --git a/completions/_tldr b/completions/_tldr index 8f6c566..59c3317 100644 --- a/completions/_tldr +++ b/completions/_tldr @@ -23,6 +23,9 @@ _tldr() { --lint"[Validate the specified tldr pages]:FILE:_files" \ --tabular"[Display lint errors in a tabular format]" \ --ignore"[Ignore comma-separated lint error codes]" \ + --format"[Format the specified tldr pages]:FILE:_files" \ + --output"[Write formatted output to the specified file]:FILE:_files" \ + --in-place"[Format pages in place]" \ {-s,--search}"[Search for pages containing a keyword]" \ {-b,--browse}"[Open page in the default web browser]" \ --list-platforms"[List available platforms]" \ diff --git a/completions/tldr.bash b/completions/tldr.bash index 3844ae5..d07f04c 100644 --- a/completions/tldr.bash +++ b/completions/tldr.bash @@ -6,7 +6,8 @@ _tldr() { local opts="-u -l -a -s -b -i -r -p -L -o -c -R -q -v -h \ --update --list --list-all --lint --tabular \ - --ignore --search --browse --list-platforms --list-languages \ + --ignore --format --output --in-place --search \ + --browse --list-platforms --list-languages \ --info --render --clean-cache --gen-config --config-path --platform \ --language --short-options --long-options --edit --offline --compact \ --no-compact --raw --no-raw --quiet --verbose --color --config --version --help" @@ -17,7 +18,7 @@ _tldr() { fi case $prev in - -r | --render | --config | --lint) + -r | --render | --config | --lint | --format | --output) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; --color) diff --git a/completions/tldr.fish b/completions/tldr.fish index 2339a10..8e9cc40 100644 --- a/completions/tldr.fish +++ b/completions/tldr.fish @@ -4,6 +4,9 @@ complete -c tldr -s a -l list-all -d "List all pages" complete -c tldr -l lint -d "Validate the specified tldr pages" -r complete -c tldr -l tabular -d "Display lint errors in a tabular format" complete -c tldr -l ignore -d "Ignore comma-separated lint error codes" -r +complete -c tldr -l format -d "Format the specified tldr pages" -r +complete -c tldr -l output -d "Write formatted output to the specified file" -r +complete -c tldr -l in-place -d "Format pages in place" complete -c tldr -s s -l search -d "Search for pages containing a keyword" complete -c tldr -s b -l browse -d "Open page in the default web browser" complete -c tldr -l list-platforms -d "List available platforms" From fba60155e11d829316382ef5473c6c9a761c2bb6 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 14 Aug 2026 15:15:48 +0530 Subject: [PATCH 54/58] chore: Improve TUI --- internal/app/format.go | 27 ++++++--- internal/app/lint.go | 94 +++++++++++++++++++++++------ internal/app/lint_test.go | 124 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 26 deletions(-) diff --git a/internal/app/format.go b/internal/app/format.go index 4ff2bd3..16eec4e 100644 --- a/internal/app/format.go +++ b/internal/app/format.go @@ -34,14 +34,20 @@ func (a *App) formatPages(cli *cmd.CLI) int { } failed := false + var rows []lintViolation for _, file := range files { - hasErrors, err := a.formatFile(file, cli) + hasErrors, err, fileRows := a.formatFile(file, cli) if err != nil { logger.Error("%v", err) } if hasErrors { failed = true } + rows = append(rows, fileRows...) + } + + if cli.Tabular { + a.writeTabular(rows) } if failed { @@ -57,19 +63,24 @@ func (a *App) formatPages(cli *cmd.CLI) int { func (a *App) formatFile( file string, cli *cmd.CLI, -) (bool, error) { +) (bool, error, []lintViolation) { result, err := a.lintFile(file, cli.Ignore...) if err != nil { - return true, err + return true, err, nil } + var rows []lintViolation for _, e := range result.Errors { - a.writeLintError(file, e, cli.Tabular) + if cli.Tabular { + rows = append(rows, lintViolation{path: file, err: e}) + } else { + a.writeLintError(file, e) + } } root, err := os.OpenRoot(filepath.Dir(file)) if err != nil { - return true, err + return true, err, nil } defer func() { _ = root.Close() @@ -77,7 +88,7 @@ func (a *App) formatFile( content, err := root.ReadFile(filepath.Base(file)) if err != nil { - return true, err + return true, err, nil } formatted := lint.Format(string(content)) @@ -86,11 +97,11 @@ func (a *App) formatFile( a.Stderr, "refraining from formatting because of a fatal error", ) - return true, nil + return true, nil, nil } err = a.writeFormatted(root, file, formatted, cli) - return len(result.Errors) > 0 || err != nil, err + return len(result.Errors) > 0 || err != nil, err, rows } // writeFormatted emits the formatted page content diff --git a/internal/app/lint.go b/internal/app/lint.go index 2155938..4c2ca5e 100644 --- a/internal/app/lint.go +++ b/internal/app/lint.go @@ -4,12 +4,20 @@ import ( "fmt" "os" "path/filepath" + "strconv" "github.com/TheRootDaemon/tlgc/cmd" "github.com/TheRootDaemon/tlgc/internal/lint" "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/termcolor" ) +// lintViolation couples a lint violation with the page it was found in. +type lintViolation struct { + path string + err lint.Error +} + // lintPages runs the linter over every page reachable // from the CLI paths and reports each violation to stderr. // @@ -24,6 +32,7 @@ func (a *App) lintPages(cli *cmd.CLI) int { } failed := false + var rows []lintViolation for _, file := range files { result, err := a.lintFile(file, cli.Ignore...) if err != nil { @@ -33,11 +42,19 @@ func (a *App) lintPages(cli *cmd.CLI) int { } for _, e := range result.Errors { - a.writeLintError(file, e, cli.Tabular) + if cli.Tabular { + rows = append(rows, lintViolation{path: file, err: e}) + } else { + a.writeLintError(file, e) + } failed = true } } + if cli.Tabular { + a.writeTabular(rows) + } + if failed { return 1 } @@ -75,23 +92,7 @@ func (a *App) lintFile( } // writeLintError reports a single lint violation for path to a.Stderr. -func (a *App) writeLintError( - path string, - e lint.Error, - tabular bool, -) { - if tabular { - _, _ = fmt.Fprintf( - a.Stderr, - "%s\t%d\t%s\t%s\t\n", - path, - e.Line, - e.Code, - e.Description, - ) - return - } - +func (a *App) writeLintError(path string, e lint.Error) { _, _ = fmt.Fprintf( a.Stderr, "%s:%d: %s %s\n", @@ -101,3 +102,60 @@ func (a *App) writeLintError( e.Description, ) } + +// writeTabular writes a decorated, +// aligned table of lint violations to a.Stderr, +// mirroring the header style of the search table. +// It writes nothing when there are no rows. +func (a *App) writeTabular(rows []lintViolation) { + if len(rows) == 0 { + return + } + + fileW, lineW, codeW := lintColumnWidths(rows) + + _, _ = fmt.Fprintln( + a.Stderr, + termcolor.Fprintf( + "bold", + "%-*s %-*s %-*s %s", + fileW, + "File", + lineW, + "Line", + codeW, + "Code", + "Description", + ), + ) + + for _, r := range rows { + _, _ = fmt.Fprintf( + a.Stderr, + "%-*s %-*d %-*s %s\n", + fileW, + r.path, + lineW, + r.err.Line, + codeW, + r.err.Code, + r.err.Description, + ) + } +} + +// lintColumnWidths returns the widths required to display the +// File, Line, and Code columns without truncation. +func lintColumnWidths(rows []lintViolation) (int, int, int) { + fileW := len("File") + lineW := len("Line") + codeW := len("Code") + + for _, r := range rows { + fileW = max(fileW, len(r.path)) + lineW = max(lineW, len(strconv.Itoa(r.err.Line))) + codeW = max(codeW, len(r.err.Code)) + } + + return fileW, lineW, codeW +} diff --git a/internal/app/lint_test.go b/internal/app/lint_test.go index eac5688..e34c807 100644 --- a/internal/app/lint_test.go +++ b/internal/app/lint_test.go @@ -1,11 +1,13 @@ package app import ( + "bytes" "path/filepath" "strings" "testing" "github.com/TheRootDaemon/tlgc/cmd" + "github.com/TheRootDaemon/tlgc/internal/lint" "github.com/stretchr/testify/assert" ) @@ -91,3 +93,125 @@ func TestLintPages(t *testing.T) { }) } } + +func TestWriteLintError(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + a := &App{ + Stderr: &buf, + } + + a.writeLintError( + "pages/a.md", + lint.Error{Code: "TLDR001", Line: 1, Description: "leading whitespace"}, + ) + + assert.Equal(t, "pages/a.md:1: TLDR001 leading whitespace\n", buf.String()) +} + +func TestWriteTabular(t *testing.T) { + t.Parallel() + + a, _, stderr := newTestApp() + + a.writeTabular( + []lintViolation{ + { + path: "a.md", + err: lint.Error{Line: 1, Code: "TLDR001", Description: "leading whitespace"}, + }, + { + path: "b.md", + err: lint.Error{Line: 2, Code: "TLDR002", Description: "space"}, + }, + }, + ) + + assert.Equal( + t, + "File Line Code Description\n"+ + "a.md 1 TLDR001 leading whitespace\n"+ + "b.md 2 TLDR002 space\n", + stderr.String(), + ) +} + +func TestWriteTabularNoRows(t *testing.T) { + t.Parallel() + + a, _, stderr := newTestApp() + + a.writeTabular(nil) + + assert.Empty(t, stderr.String()) +} + +func TestLintColumnWidths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rows []lintViolation + wantFile int + wantLine int + wantCode int + }{ + { + name: "empty_rows_uses_header_widths", + rows: nil, + wantFile: len("File"), + wantLine: len("Line"), + wantCode: len("Code"), + }, + { + name: "header_wins_over_short_values", + rows: []lintViolation{ + { + path: "a.md", + err: lint.Error{Line: 1, Code: "TLDR001", Description: "x"}, + }, + }, + wantFile: len("File"), + wantLine: len("Line"), + wantCode: len("TLDR001"), + }, + { + name: "long_values_widen_columns", + rows: []lintViolation{ + { + path: "pages/somepage.md", + err: lint.Error{Line: 123, Code: "TLDR102", Description: "some very long description"}, + }, + }, + wantFile: len("pages/somepage.md"), + wantLine: len("Line"), + wantCode: len("TLDR102"), + }, + { + name: "picks_max_across_all_rows", + rows: []lintViolation{ + { + path: "a.md", + err: lint.Error{Line: 1, Code: "TLDR001", Description: "short"}, + }, + { + path: "pages/very/long/path.md", + err: lint.Error{Line: 4567, Code: "TLDR104", Description: "a much longer description than before"}, + }, + }, + wantFile: len("pages/very/long/path.md"), + wantLine: len("4567"), + wantCode: len("TLDR104"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFile, gotLine, gotCode := lintColumnWidths(tt.rows) + assert.Equal(t, tt.wantFile, gotFile) + assert.Equal(t, tt.wantLine, gotLine) + assert.Equal(t, tt.wantCode, gotCode) + }) + } +} From f5cb14e7789e1bd1b45433b3159daa0ff052ee66 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Fri, 14 Aug 2026 16:44:48 +0530 Subject: [PATCH 55/58] chore: Move error as the last argument --- internal/app/format.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/app/format.go b/internal/app/format.go index 16eec4e..a7debe9 100644 --- a/internal/app/format.go +++ b/internal/app/format.go @@ -36,7 +36,7 @@ func (a *App) formatPages(cli *cmd.CLI) int { failed := false var rows []lintViolation for _, file := range files { - hasErrors, err, fileRows := a.formatFile(file, cli) + hasErrors, fileRows, err := a.formatFile(file, cli) if err != nil { logger.Error("%v", err) } @@ -63,10 +63,10 @@ func (a *App) formatPages(cli *cmd.CLI) int { func (a *App) formatFile( file string, cli *cmd.CLI, -) (bool, error, []lintViolation) { +) (bool, []lintViolation, error) { result, err := a.lintFile(file, cli.Ignore...) if err != nil { - return true, err, nil + return true, nil, err } var rows []lintViolation @@ -80,7 +80,7 @@ func (a *App) formatFile( root, err := os.OpenRoot(filepath.Dir(file)) if err != nil { - return true, err, nil + return true, nil, err } defer func() { _ = root.Close() @@ -88,7 +88,7 @@ func (a *App) formatFile( content, err := root.ReadFile(filepath.Base(file)) if err != nil { - return true, err, nil + return true, nil, err } formatted := lint.Format(string(content)) @@ -101,7 +101,7 @@ func (a *App) formatFile( } err = a.writeFormatted(root, file, formatted, cli) - return len(result.Errors) > 0 || err != nil, err, rows + return len(result.Errors) > 0 || err != nil, rows, err } // writeFormatted emits the formatted page content From 12977643f2da61569177cd909e69d361df8e96d6 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sat, 15 Aug 2026 22:50:30 +0530 Subject: [PATCH 56/58] chore: Format test files - Format open_test - Format wrap_test.go - Format collect_files_test - Convert collective sub tests to table driven tests for package cache - Format lint test files - Refactor render tests --- browser/open_test.go | 55 +++++- internal/app/collect_files_test.go | 27 ++- internal/cache/cache_test.go | 49 +++--- internal/cache/checksums_test.go | 223 ++++++++++++------------ internal/cache/info_test.go | 201 +++++++++++++-------- internal/lint/command_rules_test.go | 65 +++---- internal/lint/description_rules_test.go | 115 +++++------- internal/lint/example_rules_test.go | 119 +++++-------- internal/lint/filename_rules_test.go | 39 ++--- internal/lint/parse_sections_test.go | 61 +++---- internal/render/command_test.go | 188 +++++++++++--------- internal/render/style_test.go | 14 +- text/wrap_test.go | 81 +++++++-- 13 files changed, 659 insertions(+), 578 deletions(-) diff --git a/browser/open_test.go b/browser/open_test.go index bd40855..a6e2481 100644 --- a/browser/open_test.go +++ b/browser/open_test.go @@ -81,9 +81,16 @@ func TestOpenOnWSL(t *testing.T) { err := openOnWSL("https://example.com") assert.NoError(t, err) - assert.Equal(t, []call{ - {name: "explorer.exe", args: []string{"https://example.com"}}, - }, calls) + assert.Equal( + t, + []call{ + { + name: "explorer.exe", + args: []string{"https://example.com"}, + }, + }, + calls, + ) } func TestOpen(t *testing.T) { @@ -126,7 +133,12 @@ func TestOpen(t *testing.T) { assert.NoError(t, err) assert.Equal( t, - []call{{name: "open", args: []string{"https://example.com"}}}, + []call{ + { + name: "open", + args: []string{"https://example.com"}, + }, + }, calls, ) case "windows": @@ -140,7 +152,12 @@ func TestOpen(t *testing.T) { assert.NoError(t, err) assert.Equal( t, - []call{{name: "explorer.exe", args: []string{"https://example.com"}}}, + []call{ + { + name: "explorer.exe", + args: []string{"https://example.com"}, + }, + }, calls, ) } @@ -153,10 +170,30 @@ func TestHasDisplay(t *testing.T) { wayland string want bool }{ - {name: "neither set", display: "", wayland: "", want: false}, - {name: "x11 only", display: ":0", wayland: "", want: true}, - {name: "wayland only", display: "", wayland: "wayland-0", want: true}, - {name: "both set", display: ":0", wayland: "wayland-0", want: true}, + { + name: "neither set", + display: "", + wayland: "", + want: false, + }, + { + name: "x11 only", + display: ":0", + wayland: "", + want: true, + }, + { + name: "wayland only", + display: "", + wayland: "wayland-0", + want: true, + }, + { + name: "both set", + display: ":0", + wayland: "wayland-0", + want: true, + }, } for _, tt := range tests { diff --git a/internal/app/collect_files_test.go b/internal/app/collect_files_test.go index e8dee18..2e7fe93 100644 --- a/internal/app/collect_files_test.go +++ b/internal/app/collect_files_test.go @@ -52,24 +52,21 @@ func TestCollectFiles(t *testing.T) { } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - paths, want, wantErr := tt.setup(t) + t.Run(tt.name, func(t *testing.T) { + paths, want, wantErr := tt.setup(t) - got, err := collectFiles(paths) + got, err := collectFiles(paths) - if wantErr { - assert.Error(t, err) - return - } + if wantErr { + assert.Error(t, err) + return + } - assert.NoError(t, err) - sort.Strings(got) - sort.Strings(want) - assert.Equal(t, want, got) - }, - ) + assert.NoError(t, err) + sort.Strings(got) + sort.Strings(want) + assert.Equal(t, want, got) + }) } } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 5cc7aa7..ee48d4a 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -37,25 +37,22 @@ func TestNew(t *testing.T) { } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - config.ResetForTesting() - defer config.ResetForTesting() - - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - err := os.WriteFile(cfgPath, tt.config, 0o600) - require.NoError(t, err) + t.Run(tt.name, func(t *testing.T) { + config.ResetForTesting() + defer config.ResetForTesting() - t.Setenv("TLGC_CONFIG", cfgPath) - err = config.Initialize() - require.NoError(t, err) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfgPath, tt.config, 0o600) + require.NoError(t, err) - c := New() - assert.Equal(t, tt.want, c.Dir()) - }, - ) + t.Setenv("TLGC_CONFIG", cfgPath) + err = config.Initialize() + require.NoError(t, err) + + c := New() + assert.Equal(t, tt.want, c.Dir()) + }) } } @@ -67,9 +64,21 @@ func TestDir(t *testing.T) { dir string want string }{ - {name: "simple_path", dir: "/tmp/cache", want: "/tmp/cache"}, - {name: "empty_string", dir: "", want: ""}, - {name: "relative_path", dir: "./test/cache", want: "./test/cache"}, + { + name: "simple_path", + dir: "/tmp/cache", + want: "/tmp/cache", + }, + { + name: "empty_string", + dir: "", + want: "", + }, + { + name: "relative_path", + dir: "./test/cache", + want: "./test/cache", + }, } for _, tt := range tests { diff --git a/internal/cache/checksums_test.go b/internal/cache/checksums_test.go index 345d0bc..a15db2b 100644 --- a/internal/cache/checksums_test.go +++ b/internal/cache/checksums_test.go @@ -137,127 +137,128 @@ func TestLoadChecksums(t *testing.T) { func TestSaveChecksums(t *testing.T) { t.Parallel() - t.Run("saves_single_entry", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - - err := c.saveChecksums(map[string]string{ - "en.zip": "abc", - }) - require.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(dir, checksumFile)) - require.NoError(t, err) - assert.Equal(t, "abc en.zip\n", string(data)) - }) - - t.Run("saves_multiple_entries_and_round_trip", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - - original := map[string]string{ - "en.zip": "abc", - "de.zip": "def", - "zh.zip": "ghi", - } - - err := c.saveChecksums(original) - require.NoError(t, err) - - got := c.loadChecksums() - assert.Equal(t, original, got) - }) - - t.Run("overwrites_existing_file", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - - err := os.WriteFile( - filepath.Join(dir, checksumFile), - []byte("oldhash old.zip\n"), - 0o600, - ) - require.NoError(t, err) - - err = c.saveChecksums(map[string]string{ - "new.zip": "newhash", - }) - require.NoError(t, err) + tests := []struct { + name string + setupDir func(t *testing.T) string + data map[string]string + wantFile string + }{ + { + name: "saves_single_entry", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + data: map[string]string{ + "en.zip": "abc", + }, + wantFile: "abc en.zip\n", + }, + { + name: "empty_map", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + data: map[string]string{}, + wantFile: "", + }, + { + name: "special_chars_in_filename", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + data: map[string]string{ + "f!@#.zip": "abc123", + }, + wantFile: "abc123 f!@#.zip\n", + }, + { + name: "overwrites_existing_file", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, checksumFile), + []byte("oldhash old.zip\n"), + 0o600, + ) + require.NoError(t, err) + return dir + }, + data: map[string]string{ + "new.zip": "newhash", + }, + wantFile: "newhash new.zip\n", + }, + { + name: "creates_directory", + setupDir: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "sub", "dir") + }, + data: map[string]string{ + "a.zip": "h", + }, + wantFile: "h a.zip\n", + }, + } - data, err := os.ReadFile(filepath.Join(dir, checksumFile)) - require.NoError(t, err) - assert.Equal(t, "newhash new.zip\n", string(data)) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} - t.Run("creates_directory", func(t *testing.T) { - base := t.TempDir() - nested := filepath.Join(base, "sub", "dir") - c := &Cache{dir: nested} + err := c.saveChecksums(tt.data) + require.NoError(t, err) - err := c.saveChecksums(map[string]string{ - "a.zip": "h", + data, err := os.ReadFile(filepath.Join(dir, checksumFile)) + require.NoError(t, err) + assert.Equal(t, tt.wantFile, string(data)) }) - require.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(nested, checksumFile)) - require.NoError(t, err) - assert.Equal(t, "h a.zip\n", string(data)) - }) + } +} - t.Run("empty_map", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} +func TestSaveChecksumsRoundTrip(t *testing.T) { + t.Parallel() - err := c.saveChecksums(map[string]string{}) - require.NoError(t, err) + tests := []struct { + name string + data map[string]string + }{ + { + name: "multiple_entries", + data: map[string]string{ + "en.zip": "abc", + "de.zip": "def", + "zh.zip": "ghi", + }, + }, + { + name: "empty_map", + data: map[string]string{}, + }, + { + name: "large_map", + data: func() map[string]string { + data := make(map[string]string) + for i := range 20 { + name := fmt.Sprintf("tldr-pages.%d.zip", i) + hash := fmt.Sprintf("%064d", i) + data[name] = hash + } + return data + }(), + }, + } - data, err := os.ReadFile(filepath.Join(dir, checksumFile)) - require.NoError(t, err) - assert.Empty(t, data) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + c := &Cache{dir: dir} - t.Run("special_chars_in_filename", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} + require.NoError(t, c.saveChecksums(tt.data)) - err := c.saveChecksums(map[string]string{ - "f!@#.zip": "abc123", + got := c.loadChecksums() + assert.Equal(t, tt.data, got) }) - require.NoError(t, err) - - data, err := os.ReadFile(filepath.Join(dir, checksumFile)) - require.NoError(t, err) - assert.Equal(t, "abc123 f!@#.zip\n", string(data)) - }) - - t.Run("round_trip_empty_map", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - - err := c.saveChecksums(map[string]string{}) - require.NoError(t, err) - - got := c.loadChecksums() - assert.Equal(t, map[string]string{}, got) - }) - - t.Run("round_trip_large_map", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - - original := make(map[string]string) - for i := range 20 { - name := fmt.Sprintf("tldr-pages.%d.zip", i) - hash := fmt.Sprintf("%064d", i) - original[name] = hash - } - - err := c.saveChecksums(original) - require.NoError(t, err) - - got := c.loadChecksums() - assert.Equal(t, original, got) - }) + } } func TestDownloadChecksum(t *testing.T) { diff --git a/internal/cache/info_test.go b/internal/cache/info_test.go index 5a5ef73..fca55f8 100644 --- a/internal/cache/info_test.go +++ b/internal/cache/info_test.go @@ -13,61 +13,98 @@ import ( func TestAge(t *testing.T) { t.Parallel() - t.Run("uses_checksum_file_mtime", func(t *testing.T) { - dir := t.TempDir() - sumPath := filepath.Join(dir, checksumFile) - err := os.WriteFile(sumPath, []byte("sums"), 0o644) - require.NoError(t, err) - err = os.Chtimes( - sumPath, - time.Now().Add(-1*time.Hour), - time.Now().Add(-1*time.Hour), - ) - require.NoError(t, err) + tests := []struct { + name string + setupDir func(t *testing.T) string + wantMin time.Duration + wantMax time.Duration + }{ + { + name: "uses_checksum_file_mtime", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + checksumPath := filepath.Join(dir, checksumFile) - c := &Cache{dir: dir} - age, err := c.Age() - require.NoError(t, err) - assert.Greater(t, age, 55*time.Minute) - assert.Less(t, age, 65*time.Minute) - }) + err := os.WriteFile(checksumPath, []byte("sums"), 0o644) + require.NoError(t, err) - t.Run("falls_back_to_cache_dir_mtime", func(t *testing.T) { - dir := t.TempDir() - err := os.Chtimes( - dir, - time.Now().Add(-2*time.Hour), - time.Now().Add(-2*time.Hour), - ) - require.NoError(t, err) + past := time.Now().Add(-1 * time.Hour) + require.NoError(t, os.Chtimes(checksumPath, past, past)) - c := &Cache{dir: dir} - age, err := c.Age() - require.NoError(t, err) - assert.Greater(t, age, 115*time.Minute) - assert.Less(t, age, 125*time.Minute) - }) + return dir + }, + wantMin: 55 * time.Minute, + wantMax: 65 * time.Minute, + }, + { + name: "falls_back_to_cache_dir_mtime", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + past := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(dir, past, past)) + return dir + }, + wantMin: 115 * time.Minute, + wantMax: 125 * time.Minute, + }, + } - t.Run("error_on_non_existent_dir", func(t *testing.T) { - c := &Cache{dir: "/nonexistent/path"} - _, err := c.Age() - assert.Error(t, err) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setupDir(t) + c := &Cache{dir: dir} - t.Run("error_on_future_mtime", func(t *testing.T) { - dir := t.TempDir() - future := time.Now().Add(1 * time.Hour) - err := os.Chtimes(dir, future, future) - require.NoError(t, err) + age, err := c.Age() + require.NoError(t, err) + assert.Greater(t, age, tt.wantMin) + assert.Less(t, age, tt.wantMax) + }) + } +} - c := &Cache{dir: dir} - _, err = c.Age() - assert.Error(t, err) - assert.Contains(t, err.Error(), "future") - }) +func TestAge_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + wantMsg string + }{ + { + name: "error_on_non_existent_dir", + setupDir: func(t *testing.T) string { + return "/nonexistent/path" + }, + }, + { + name: "error_on_future_mtime", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + future := time.Now().Add(1 * time.Hour) + require.NoError(t, os.Chtimes(dir, future, future)) + return dir + }, + wantMsg: "future", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Cache{dir: tt.setupDir(t)} + + _, err := c.Age() + assert.Error(t, err) + + if tt.wantMsg != "" { + assert.Contains(t, err.Error(), tt.wantMsg) + } + }) + } } func TestInfo(t *testing.T) { + t.Parallel() + t.Run("returns_info_for_valid_cache", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) @@ -103,31 +140,6 @@ func TestInfo(t *testing.T) { assert.Equal(t, 2, info.LanguageStats[0].Platforms[1].Pages) }) - t.Run("error_on_non_existent_dir", func(t *testing.T) { - c := &Cache{dir: "/nonexistent/path"} - _, err := c.Info() - assert.Error(t, err) - assert.Contains(t, err.Error(), "cache directory") - }) - - t.Run("error_on_file_instead_of_dir", func(t *testing.T) { - dir := t.TempDir() - filePath := filepath.Join(dir, "not_a_dir") - require.NoError(t, os.WriteFile(filePath, nil, 0o644)) - - c := &Cache{dir: filePath} - _, err := c.Info() - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a directory") - }) - - t.Run("empty_cache_returns_zero_pages", func(t *testing.T) { - dir := t.TempDir() - c := &Cache{dir: dir} - _, err := c.Info() - assert.Error(t, err) - }) - t.Run("cache_with_multiple_languages", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "pages.en", "common"), 0o755)) @@ -146,6 +158,53 @@ func TestInfo(t *testing.T) { }) } +func TestInfo_Errors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDir func(t *testing.T) string + wantMsg string + }{ + { + name: "error_on_non_existent_dir", + setupDir: func(t *testing.T) string { + return "/nonexistent/path" + }, + wantMsg: "cache directory", + }, + { + name: "error_on_file_instead_of_dir", + setupDir: func(t *testing.T) string { + dir := t.TempDir() + filePath := filepath.Join(dir, "not_a_dir") + require.NoError(t, os.WriteFile(filePath, nil, 0o644)) + return filePath + }, + wantMsg: "not a directory", + }, + { + name: "empty_cache_returns_zero_pages", + setupDir: func(t *testing.T) string { + return t.TempDir() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Cache{dir: tt.setupDir(t)} + + _, err := c.Info() + + assert.Error(t, err) + if tt.wantMsg != "" { + assert.Contains(t, err.Error(), tt.wantMsg) + } + }) + } +} + func TestLanguageStats(t *testing.T) { t.Parallel() diff --git a/internal/lint/command_rules_test.go b/internal/lint/command_rules_test.go index fce8ab3..95da44a 100644 --- a/internal/lint/command_rules_test.go +++ b/internal/lint/command_rules_test.go @@ -55,14 +55,11 @@ func TestCheckCommandWhitespace(t *testing.T) { {name: "escaped trailing space passes", lines: []parsedLine{escapedTrailingSpace}, wantCodes: nil}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkCommandWhitespace(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkCommandWhitespace(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -115,14 +112,11 @@ func TestCheckCommandDescriptionAnnotated(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkCommandDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkCommandDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -180,14 +174,11 @@ func TestCheckExampleDescriptionAnnotated(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkExampleDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkExampleDescriptionAnnotated(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -224,14 +215,11 @@ func TestCheckCommandClosingBacktick(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkCommandClosingBacktick(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkCommandClosingBacktick(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -268,13 +256,10 @@ func TestCheckCommandNotEmpty(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkCommandNotEmpty(&parsedPage{lines: tt.lines}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkCommandNotEmpty(&parsedPage{lines: tt.lines}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } diff --git a/internal/lint/description_rules_test.go b/internal/lint/description_rules_test.go index e5f4ad1..ab41e9e 100644 --- a/internal/lint/description_rules_test.go +++ b/internal/lint/description_rules_test.go @@ -50,14 +50,11 @@ func TestCheckDescriptionStartsWithCapital(t *testing.T) { {name: "empty description passes", descriptions: []parsedLine{empty}, wantCodes: nil}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkDescriptionStartsWithCapital(&parsedPage{descriptions: tt.descriptions}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkDescriptionStartsWithCapital(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -98,14 +95,11 @@ func TestCheckDescriptionEndsWithPeriod(t *testing.T) { {name: "empty description passes", descriptions: []parsedLine{empty}, wantCodes: nil}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkDescriptionEndsWithPeriod(&parsedPage{descriptions: tt.descriptions}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkDescriptionEndsWithPeriod(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -153,14 +147,11 @@ func TestCheckInformationLinkLabel(t *testing.T) { {name: "lowercase label fails", descriptions: []parsedLine{lowercase}, wantCodes: []string{"TLDR016"}}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkInformationLinkLabel(&parsedPage{descriptions: tt.descriptions}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkInformationLinkLabel(&parsedPage{descriptions: tt.descriptions}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -194,14 +185,11 @@ func TestCheckInformationLinkBrackets(t *testing.T) { {name: "missing opening bracket fails", infoLinks: []parsedLine{missingOpen}, wantCodes: []string{"TLDR017"}}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkInformationLinkBrackets(&parsedPage{infoLinks: tt.infoLinks}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkInformationLinkBrackets(&parsedPage{infoLinks: tt.infoLinks}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -240,14 +228,11 @@ func TestCheckSingleInformationLink(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkSingleInformationLink(&parsedPage{infoLinks: tt.infoLinks}, r) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkSingleInformationLink(&parsedPage{infoLinks: tt.infoLinks}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -299,20 +284,17 @@ func TestCheckNoteLabelFormat(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkNoteLabelFormat( - &parsedPage{ - descriptions: tt.descriptions, - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkNoteLabelFormat( + &parsedPage{ + descriptions: tt.descriptions, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -399,19 +381,16 @@ func TestCheckStandardTermsInBackticks(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkStandardTermsInBackticks( - &parsedPage{ - descriptions: tt.descriptions, - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkStandardTermsInBackticks( + &parsedPage{ + descriptions: tt.descriptions, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } diff --git a/internal/lint/example_rules_test.go b/internal/lint/example_rules_test.go index 658af73..54c4a6a 100644 --- a/internal/lint/example_rules_test.go +++ b/internal/lint/example_rules_test.go @@ -30,19 +30,11 @@ func TestCheckExampleDescriptionEndsWithColon(t *testing.T) { {name: "empty description passes", exampleSections: []commandSection{empty}, wantCodes: nil}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkExampleDescriptionEndsWithColon( - &parsedPage{ - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkExampleDescriptionEndsWithColon(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -163,20 +155,17 @@ func TestCheckExampleDescriptionSurroundedByBlankLines(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkExampleDescriptionSurroundedByBlankLines( - &parsedPage{ - lines: tt.lines, - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkExampleDescriptionSurroundedByBlankLines( + &parsedPage{ + lines: tt.lines, + exampleSections: tt.exampleSections, + }, + r, + ) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -209,19 +198,11 @@ func TestCheckExampleDescriptionStartsWithCapital(t *testing.T) { {name: "empty description passes", exampleSections: []commandSection{empty}, wantCodes: nil}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkExampleDescriptionStartsWithCapital( - &parsedPage{ - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkExampleDescriptionStartsWithCapital(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -240,14 +221,11 @@ func TestCheckMaximumExampleCount(t *testing.T) { {name: "nine examples fail", exampleSections: makeExampleSections(section, 9), wantCode: "TLDR019"}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkMaximumExampleCount(&parsedPage{exampleSections: tt.exampleSections}, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkMaximumExampleCount(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -275,19 +253,11 @@ func TestCheckInfinitiveTense(t *testing.T) { {name: "gerund fails", exampleSections: []commandSection{gerund}, wantCodes: []string{"TLDR104"}}, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkInfinitiveTense( - &parsedPage{ - exampleSections: tt.exampleSections, - }, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkInfinitiveTense(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -338,17 +308,11 @@ func TestCheckSingleCommandPerExample(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkSingleCommandPerExample( - &parsedPage{exampleSections: tt.exampleSections}, - r, - ) - require.Equal(t, tt.wantCodes, errorCodes(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkSingleCommandPerExample(&parsedPage{exampleSections: tt.exampleSections}, r) + require.Equal(t, tt.wantCodes, errorCodes(r)) + }) } } @@ -391,13 +355,10 @@ func TestLineIndex(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := lineIndex(tt.lines, tt.lineNumber) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := lineIndex(tt.lines, tt.lineNumber) + require.Equal(t, tt.want, got) + }) } } diff --git a/internal/lint/filename_rules_test.go b/internal/lint/filename_rules_test.go index c65e5b8..40837b3 100644 --- a/internal/lint/filename_rules_test.go +++ b/internal/lint/filename_rules_test.go @@ -44,14 +44,11 @@ func TestCheckFileExtension(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkFileExtension(tt.filename, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkFileExtension(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -83,14 +80,11 @@ func TestCheckFilenameWhitespace(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkFilenameWhitespace(tt.filename, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkFilenameWhitespace(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } @@ -168,13 +162,10 @@ func TestCheckForbiddenFilenameCharacters(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - r := &Result{} - checkForbiddenFilenameCharacters(tt.filename, r) - require.Equal(t, tt.wantCode, errorCode(r)) - }, - ) + t.Run(tt.name, func(t *testing.T) { + r := &Result{} + checkForbiddenFilenameCharacters(tt.filename, r) + require.Equal(t, tt.wantCode, errorCode(r)) + }) } } diff --git a/internal/lint/parse_sections_test.go b/internal/lint/parse_sections_test.go index 0b8ad9d..0318664 100644 --- a/internal/lint/parse_sections_test.go +++ b/internal/lint/parse_sections_test.go @@ -70,13 +70,10 @@ func TestIndexOfTitle(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := indexOfTitle(tt.lines) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := indexOfTitle(tt.lines) + require.Equal(t, tt.want, got) + }) } } @@ -143,13 +140,10 @@ func TestNextContentIndex(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := nextContentIndex(tt.lines, tt.i) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := nextContentIndex(tt.lines, tt.i) + require.Equal(t, tt.want, got) + }) } } @@ -260,15 +254,12 @@ func TestCollectDescriptions(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - description, info, next := collectDescriptions(tt.lines, tt.i) - require.Equal(t, tt.wantDescription, description) - require.Equal(t, tt.wantInfo, info) - require.Equal(t, tt.wantNext, next) - }, - ) + t.Run(tt.name, func(t *testing.T) { + description, info, next := collectDescriptions(tt.lines, tt.i) + require.Equal(t, tt.wantDescription, description) + require.Equal(t, tt.wantInfo, info) + require.Equal(t, tt.wantNext, next) + }) } } @@ -387,13 +378,10 @@ func TestCollectExampleSections(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got := collectExampleSections(tt.lines, tt.i) - require.Equal(t, tt.want, got) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got := collectExampleSections(tt.lines, tt.i) + require.Equal(t, tt.want, got) + }) } } @@ -488,14 +476,11 @@ func TestBuildExampleSection(t *testing.T) { }, } for _, tt := range tests { - t.Run( - tt.name, - func(t *testing.T) { - got, next := buildExampleSection(tt.lines, tt.i) - require.Equal(t, tt.want, got) - require.Equal(t, tt.wantNext, next) - }, - ) + t.Run(tt.name, func(t *testing.T) { + got, next := buildExampleSection(tt.lines, tt.i) + require.Equal(t, tt.want, got) + require.Equal(t, tt.wantNext, next) + }) } } diff --git a/internal/render/command_test.go b/internal/render/command_test.go index efab2e9..63d3858 100644 --- a/internal/render/command_test.go +++ b/internal/render/command_test.go @@ -89,94 +89,108 @@ func TestRenderCommand(t *testing.T) { assert.Equal(t, tt.want, buf.String()) }) } +} + +func TestRenderCommand_Wrapping(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 15, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("some very long command")) + assert.NoError(t, err) + assert.Equal(t, " some very long\n command\n", buf.String()) +} + +func TestRenderCommand_ShortOption(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleShort, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd -s\n", buf.String()) +} + +func TestRenderCommand_CombinedOption(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleCombined, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd [-s|--long]\n", buf.String()) +} + +func TestRenderCommand_Colorized(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: true, + style: config.DefaultStyleConfig(), + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("echo hello")) + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "\x1b[36m") + assert.Contains(t, output, "\x1b[0m") + assert.Contains(t, output, "echo") + assert.Contains(t, output, "hello") +} + +func TestRenderCommand_WriteError(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } - t.Run("wrapping produces multiple lines", func(t *testing.T) { - r := &Renderer{ - useColor: false, - output: config.OutputConfig{ - OptionStyle: config.OptionStyleLong, - LineLength: 15, - }, - indent: config.IndentConfig{ - Example: 4, - }, - } - var buf strings.Builder - err := r.renderCommand(&buf, ParseCommand("some very long command")) - assert.NoError(t, err) - assert.Equal(t, " some very long\n command\n", buf.String()) - }) - - t.Run("option rendered with short style", func(t *testing.T) { - r := &Renderer{ - useColor: false, - output: config.OutputConfig{ - OptionStyle: config.OptionStyleShort, - LineLength: 0, - }, - indent: config.IndentConfig{ - Example: 4, - }, - } - var buf strings.Builder - err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) - assert.NoError(t, err) - assert.Equal(t, " cmd -s\n", buf.String()) - }) - - t.Run("option rendered with combined style", func(t *testing.T) { - r := &Renderer{ - useColor: false, - output: config.OutputConfig{ - OptionStyle: config.OptionStyleCombined, - LineLength: 0, - }, - indent: config.IndentConfig{ - Example: 4, - }, - } - var buf strings.Builder - err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) - assert.NoError(t, err) - assert.Equal(t, " cmd [-s|--long]\n", buf.String()) - }) - - t.Run("colorized output contains ANSI sequences", func(t *testing.T) { - r := &Renderer{ - useColor: true, - style: config.DefaultStyleConfig(), - output: config.OutputConfig{ - OptionStyle: config.OptionStyleLong, - LineLength: 0, - }, - indent: config.IndentConfig{ - Example: 4, - }, - } - var buf strings.Builder - err := r.renderCommand(&buf, ParseCommand("echo hello")) - assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "\x1b[36m") - assert.Contains(t, output, "\x1b[0m") - assert.Contains(t, output, "echo") - assert.Contains(t, output, "hello") - }) - - t.Run("error from renderCommandLine propagates", func(t *testing.T) { - r := &Renderer{ - useColor: false, - output: config.OutputConfig{ - OptionStyle: config.OptionStyleLong, - LineLength: 0, - }, - indent: config.IndentConfig{ - Example: 4, - }, - } - err := r.renderCommand(&errorWriter{err: errors.New("write error")}, ParseCommand("echo hi")) - assert.ErrorContains(t, err, "write error") - }) + err := r.renderCommand(&errorWriter{err: errors.New("write error")}, ParseCommand("echo hi")) + assert.ErrorContains(t, err, "write error") } func TestRenderCommandLine(t *testing.T) { diff --git a/internal/render/style_test.go b/internal/render/style_test.go index 9936115..5239476 100644 --- a/internal/render/style_test.go +++ b/internal/render/style_test.go @@ -59,13 +59,15 @@ func TestApplyStyle(t *testing.T) { } }) } +} + +func TestApplyStyle_WithEmptyStyle(t *testing.T) { + t.Parallel() - t.Run("useColor true with empty style returns input unchanged", func(t *testing.T) { - r := &Renderer{useColor: true} - input := "some text" - got := r.applyStyle(config.OutputStyle{}, input) - assert.Equal(t, input, got) - }) + r := &Renderer{useColor: true} + input := "some text" + got := r.applyStyle(config.OutputStyle{}, input) + assert.Equal(t, input, got) } func TestStyleForSegment(t *testing.T) { diff --git a/text/wrap_test.go b/text/wrap_test.go index e9b8020..6ab1816 100644 --- a/text/wrap_test.go +++ b/text/wrap_test.go @@ -14,17 +14,78 @@ func TestWrap(t *testing.T) { indent string want string }{ - {name: "short line", input: "hello world", maxLen: 80, indent: " ", want: "hello world"}, - {name: "long line", input: "this is a long line that should be wrapped at word boundaries", maxLen: 20, indent: "", want: "this is a long line\nthat should be\nwrapped at word\nboundaries"}, - {name: "with indent", input: "this is a long line that should be indented", maxLen: 15, indent: "> ", want: "this is a long\n> line that\n> should be\n> indented"}, - {name: "empty", input: "", maxLen: 80, indent: "", want: ""}, - {name: "zero maxLen", input: "hello world", maxLen: 0, indent: "", want: "hello\nworld"}, - {name: "negative maxLen", input: "hello world", maxLen: -1, indent: "", want: "hello\nworld"}, - {name: "word longer than maxLen", input: "superlongword that is long", maxLen: 5, indent: "", want: "superlongword\nthat\nis\nlong"}, - {name: "exact fit", input: "1234567890", maxLen: 10, indent: "", want: "1234567890"}, - {name: "exact fit with space", input: "hello world", maxLen: 11, indent: "", want: "hello world"}, - {name: "single word", input: "hello", maxLen: 10, indent: "", want: "hello"}, + { + name: "short line", + input: "hello world", + maxLen: 80, + indent: " ", + want: "hello world", + }, + { + name: "long line", + input: "this is a long line that should be wrapped at word boundaries", + maxLen: 20, + indent: "", + want: "this is a long line\nthat should be\nwrapped at word\nboundaries", + }, + { + name: "with indent", + input: "this is a long line that should be indented", + maxLen: 15, + indent: "> ", + want: "this is a long\n> line that\n> should be\n> indented", + }, + { + name: "empty", + input: "", + maxLen: 80, + indent: "", + want: "", + }, + { + name: "zero maxLen", + input: "hello world", + maxLen: 0, + indent: "", + want: "hello\nworld", + }, + { + name: "negative maxLen", + input: "hello world", + maxLen: -1, + indent: "", + want: "hello\nworld", + }, + { + name: "word longer than maxLen", + input: "superlongword that is long", + maxLen: 5, + indent: "", + want: "superlongword\nthat\nis\nlong", + }, + { + name: "exact fit", + input: "1234567890", + maxLen: 10, + indent: "", + want: "1234567890", + }, + { + name: "exact fit with space", + input: "hello world", + maxLen: 11, + indent: "", + want: "hello world", + }, + { + name: "single word", + input: "hello", + maxLen: 10, + indent: "", + want: "hello", + }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Wrap(tt.input, tt.maxLen, tt.indent) From b49b0c9d987daad685fd1f9bf6e09530a519e016 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Sun, 16 Aug 2026 20:52:18 +0530 Subject: [PATCH 57/58] feat(cmd): Enforce parent-child flag dependencies Modifier flags now require their parent operation. Refactored validate into helpers, added a modifierDependencies table. --- cmd/flag_dependencies.go | 166 +++++++++++++++ cmd/flag_dependencies_test.go | 369 ++++++++++++++++++++++++++++++++++ cmd/parse_test.go | 131 ++++++++++-- cmd/validate.go | 59 ++++-- cmd/validate_test.go | 257 ++++++++++++++++++++++- 5 files changed, 944 insertions(+), 38 deletions(-) create mode 100644 cmd/flag_dependencies.go create mode 100644 cmd/flag_dependencies_test.go diff --git a/cmd/flag_dependencies.go b/cmd/flag_dependencies.go new file mode 100644 index 0000000..f052131 --- /dev/null +++ b/cmd/flag_dependencies.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "strings" + + "github.com/TheRootDaemon/tlgc/termcolor" +) + +// flagDependency describes a modifier flag +// and the operation flags that it depends on. +type flagDependency struct { + flag string // flag is the modifier flag being validated. + parents []string // parents contains the names of the operations that permit the modifier. + present func(*CLI) bool // present reports whether the modifier flag is enabled in the CLI configuration. + valid func(*CLI) bool // valid reports whether the modifier can be used with the current CLI configuration. +} + +// modifierDependencies contains the dependency rules for modifier flags. +// +// Each entry describes which operation +// or operations must be active for a particular modifier flag to be valid. +// These rules are evaluated by validateFlagDependencies. +var modifierDependencies = []flagDependency{ + { + flag: "--output", + parents: []string{"--format"}, + present: func(c *CLI) bool { return c.Output != "" }, + valid: func(c *CLI) bool { return c.Format }, + }, + { + flag: "--in-place", + parents: []string{"--format"}, + present: func(c *CLI) bool { return c.InPlace }, + valid: func(c *CLI) bool { return c.Format }, + }, + { + flag: "--tabular", + parents: []string{"--lint", "--format"}, + present: func(c *CLI) bool { return c.Tabular }, + valid: func(c *CLI) bool { return c.Lint || c.Format }, + }, + { + flag: "--ignore", + parents: []string{"--lint", "--format"}, + present: func(c *CLI) bool { return len(c.Ignore) > 0 }, + valid: func(c *CLI) bool { return c.Lint || c.Format }, + }, + { + flag: "--platform", + parents: []string{"a page", "--browse", "--list", "--search"}, + present: func(c *CLI) bool { return c.Platform != "" }, + valid: func(c *CLI) bool { + return c.pageLookup() || c.Browse || c.List || c.Search != "" + }, + }, + { + flag: "--language", + parents: []string{"a page", "--browse", "--search", "--update"}, + present: func(c *CLI) bool { return len(c.Languages) > 0 }, + valid: func(c *CLI) bool { + return c.pageLookup() || c.Browse || c.Search != "" || c.Update + }, + }, + { + flag: "--offline", + parents: []string{"a page", "--browse"}, + present: func(c *CLI) bool { return c.Offline }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Browse }, + }, + { + flag: "--compact", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.Compact }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--no-compact", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.NoCompact }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--raw", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.Raw }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--no-raw", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.NoRaw }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--short-options", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.ShortOptions }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--long-options", + parents: []string{"a page", "--render"}, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + present: func(c *CLI) bool { return c.LongOptions }, + }, + { + flag: "--edit", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.Edit }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, + { + flag: "--color", + parents: []string{"a page", "--render"}, + present: func(c *CLI) bool { return c.Color != "auto" }, + valid: func(c *CLI) bool { return c.pageLookup() || c.Render != "" }, + }, +} + +// validateFlagDependencies validates the dependencies +// between modifier flags and their parent operations. +// +// A modifier flag is valid only when at least one of the operations +// listed in its corresponding flagDependency is active. +// If a modifier is used without a valid parent operation, +// validateFlagDependencies returns a usage error +// describing the required operation. +func validateFlagDependencies(cli *CLI) error { + for _, dependency := range modifierDependencies { + if !dependency.present(cli) || dependency.valid(cli) { + continue + } + return fmtUsage( + "flag %s requires %s", + termcolor.Sprint("bold blue", dependency.flag), + formatParents(dependency.parents), + ) + } + + return nil +} + +// pageLookup reports whether the CLI represents a bare page lookup. +// +// A bare page lookup occurs when at least one page argument is provided +// and none of the explicit page-processing operations are active +// that is, --browse, --lint, or --format are active. +func (c *CLI) pageLookup() bool { + return len(c.Page) > 0 && !c.Browse && !c.Lint && !c.Format +} + +// formatParents formats the parent flags as a human-readable list +// separated by "or", such as "X or Y" or "X, Y, or Z". +func formatParents(items []string) string { + if len(items) == 1 { + return termcolor.Sprint("bold blue", items[0]) + } + + styled := make([]string, len(items)) + for i, item := range items { + styled[i] = termcolor.Sprint("bold blue", item) + } + + head := strings.Join(styled[:len(styled)-1], ", ") + return head + " or " + styled[len(styled)-1] +} diff --git a/cmd/flag_dependencies_test.go b/cmd/flag_dependencies_test.go new file mode 100644 index 0000000..227be41 --- /dev/null +++ b/cmd/flag_dependencies_test.go @@ -0,0 +1,369 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestModifierDependencies(t *testing.T) { + t.Parallel() + + assert.NotEmpty(t, modifierDependencies) + + for _, dependency := range modifierDependencies { + t.Run(dependency.flag, func(t *testing.T) { + assert.True(t, strings.HasPrefix(dependency.flag, "--")) + assert.NotEmpty(t, dependency.parents) + + for _, parent := range dependency.parents { + assert.NotEmpty(t, parent) + } + + assert.NotNil(t, dependency.present) + assert.NotNil(t, dependency.valid) + }) + } +} + +func TestValidateFlagDependencies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + errString string + }{ + // invalid: modifier present with no valid parent + { + name: "output_without_parent", + cli: CLI{Output: "out.md"}, + wantErr: true, + errString: "flag --output requires --format", + }, + { + name: "in_place_without_parent", + cli: CLI{InPlace: true}, + wantErr: true, + errString: "flag --in-place requires --format", + }, + { + name: "tabular_without_parent", + cli: CLI{Tabular: true}, + wantErr: true, + errString: "flag --tabular requires --lint or --format", + }, + { + name: "ignore_without_parent", + cli: CLI{Ignore: []string{"TLDR001"}}, + wantErr: true, + errString: "flag --ignore requires --lint or --format", + }, + { + name: "platform_without_parent", + cli: CLI{Platform: "linux"}, + wantErr: true, + errString: "flag --platform requires a page, --browse, --list or --search", + }, + { + name: "language_without_parent", + cli: CLI{Languages: []string{"en"}}, + wantErr: true, + errString: "flag --language requires a page, --browse, --search or --update", + }, + { + name: "offline_without_parent", + cli: CLI{Offline: true}, + wantErr: true, + errString: "flag --offline requires a page or --browse", + }, + { + name: "compact_without_parent", + cli: CLI{Compact: true}, + wantErr: true, + errString: "flag --compact requires a page or --render", + }, + { + name: "no_compact_without_parent", + cli: CLI{NoCompact: true}, + wantErr: true, + errString: "flag --no-compact requires a page or --render", + }, + { + name: "raw_without_parent", + cli: CLI{Raw: true}, + wantErr: true, + errString: "flag --raw requires a page or --render", + }, + { + name: "no_raw_without_parent", + cli: CLI{NoRaw: true}, + wantErr: true, + errString: "flag --no-raw requires a page or --render", + }, + { + name: "short_options_without_parent", + cli: CLI{ShortOptions: true}, + wantErr: true, + errString: "flag --short-options requires a page or --render", + }, + { + name: "long_options_without_parent", + cli: CLI{LongOptions: true}, + wantErr: true, + errString: "flag --long-options requires a page or --render", + }, + { + name: "edit_without_parent", + cli: CLI{Edit: true}, + wantErr: true, + errString: "flag --edit requires a page or --render", + }, + { + name: "color_without_parent", + cli: CLI{Color: "always"}, + wantErr: true, + errString: "flag --color requires a page or --render", + }, + { + name: "color_auto_without_parent", + cli: CLI{Color: "auto"}, + }, + + // valid: modifier with at least one active parent + { + name: "output_with_format", + cli: CLI{Output: "out.md", Format: true}, + }, + { + name: "in_place_with_format", + cli: CLI{InPlace: true, Format: true}, + }, + { + name: "tabular_with_lint", + cli: CLI{Tabular: true, Lint: true}, + }, + { + name: "tabular_with_format", + cli: CLI{Tabular: true, Format: true}, + }, + { + name: "ignore_with_lint", + cli: CLI{Ignore: []string{"TLDR001"}, Lint: true}, + }, + { + name: "ignore_with_format", + cli: CLI{Ignore: []string{"TLDR001"}, Format: true}, + }, + { + name: "platform_with_page", + cli: CLI{Platform: "linux", Page: []string{"tar"}}, + }, + { + name: "platform_with_browse", + cli: CLI{Platform: "linux", Browse: true, Page: []string{"tar"}}, + }, + { + name: "platform_with_list", + cli: CLI{Platform: "linux", List: true}, + }, + { + name: "platform_with_search", + cli: CLI{Platform: "linux", Search: "ngi"}, + }, + { + name: "language_with_page", + cli: CLI{Languages: []string{"en"}, Page: []string{"tar"}}, + }, + { + name: "language_with_browse", + cli: CLI{Languages: []string{"en"}, Browse: true, Page: []string{"tar"}}, + }, + { + name: "language_with_search", + cli: CLI{Languages: []string{"en"}, Search: "ngi"}, + }, + { + name: "language_with_update", + cli: CLI{Languages: []string{"en"}, Update: true}, + }, + { + name: "offline_with_page", + cli: CLI{Offline: true, Page: []string{"tar"}}, + }, + { + name: "offline_with_browse", + cli: CLI{Offline: true, Browse: true, Page: []string{"tar"}}, + }, + { + name: "compact_with_page", + cli: CLI{Compact: true, Page: []string{"tar"}}, + }, + { + name: "compact_with_render", + cli: CLI{Compact: true, Render: "file.md"}, + }, + { + name: "no_compact_with_page", + cli: CLI{NoCompact: true, Page: []string{"tar"}}, + }, + { + name: "no_compact_with_render", + cli: CLI{NoCompact: true, Render: "file.md"}, + }, + { + name: "raw_with_page", + cli: CLI{Raw: true, Page: []string{"tar"}}, + }, + { + name: "raw_with_render", + cli: CLI{Raw: true, Render: "file.md"}, + }, + { + name: "no_raw_with_page", + cli: CLI{NoRaw: true, Page: []string{"tar"}}, + }, + { + name: "no_raw_with_render", + cli: CLI{NoRaw: true, Render: "file.md"}, + }, + { + name: "short_options_with_page", + cli: CLI{ShortOptions: true, Page: []string{"tar"}}, + }, + { + name: "short_options_with_render", + cli: CLI{ShortOptions: true, Render: "file.md"}, + }, + { + name: "long_options_with_page", + cli: CLI{LongOptions: true, Page: []string{"tar"}}, + }, + { + name: "long_options_with_render", + cli: CLI{LongOptions: true, Render: "file.md"}, + }, + { + name: "edit_with_page", + cli: CLI{Edit: true, Page: []string{"tar"}}, + }, + { + name: "edit_with_render", + cli: CLI{Edit: true, Render: "file.md"}, + }, + { + name: "color_with_page", + cli: CLI{Color: "always", Page: []string{"tar"}}, + }, + { + name: "color_with_render", + cli: CLI{Color: "never", Render: "file.md"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli := tt.cli + if cli.Color == "" { + cli.Color = "auto" + } + err := validateFlagDependencies(&cli) + if tt.wantErr { + assert.ErrorContains(t, err, tt.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPageLookup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + want bool + }{ + { + name: "no_page", + cli: CLI{}, + want: false, + }, + { + name: "page", + cli: CLI{Page: []string{"tar"}}, + want: true, + }, + { + name: "multiple_pages", + cli: CLI{Page: []string{"tar", "git"}}, + want: true, + }, + { + name: "page_with_browse", + cli: CLI{Page: []string{"tar"}, Browse: true}, + want: false, + }, + { + name: "page_with_lint", + cli: CLI{Page: []string{"file.md"}, Lint: true}, + want: false, + }, + { + name: "page_with_format", + cli: CLI{Page: []string{"file.md"}, Format: true}, + want: false, + }, + { + name: "browse_without_page", + cli: CLI{Browse: true}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.cli.pageLookup()) + }) + } +} + +func TestFormatParents(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []string + want string + }{ + { + name: "single", + items: []string{"--format"}, + want: "--format", + }, + { + name: "two", + items: []string{"--lint", "--format"}, + want: "--lint or --format", + }, + { + name: "three", + items: []string{"a page", "--browse", "--render"}, + want: "a page, --browse or --render", + }, + { + name: "four", + items: []string{"a page", "--browse", "--list", "--search"}, + want: "a page, --browse, --list or --search", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatParents(tt.items)) + }) + } +} diff --git a/cmd/parse_test.go b/cmd/parse_test.go index 6b6dd62..609aad0 100644 --- a/cmd/parse_test.go +++ b/cmd/parse_test.go @@ -191,14 +191,14 @@ func TestParse(t *testing.T) { // options { name: "platform_short", - args: []string{"-p", "linux", "-u"}, + args: []string{"-p", "linux", "tar"}, check: func(t *testing.T, cli *CLI) { assert.Equal(t, "linux", cli.Platform) }, }, { name: "platform_long", - args: []string{"--platform", "osx", "-u"}, + args: []string{"--platform", "osx", "tar"}, check: func(t *testing.T, cli *CLI) { assert.Equal(t, "osx", cli.Platform) }, @@ -233,56 +233,56 @@ func TestParse(t *testing.T) { }, { name: "offline_short", - args: []string{"-o", "-u"}, + args: []string{"-o", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Offline) }, }, { name: "offline_long", - args: []string{"--offline", "-u"}, + args: []string{"--offline", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Offline) }, }, { name: "compact_short", - args: []string{"-c", "-u"}, + args: []string{"-c", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Compact) }, }, { name: "compact_long", - args: []string{"--compact", "-u"}, + args: []string{"--compact", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Compact) }, }, { name: "no_compact", - args: []string{"--no-compact", "-u"}, + args: []string{"--no-compact", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.NoCompact) }, }, { name: "raw_short", - args: []string{"-R", "-u"}, + args: []string{"-R", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Raw) }, }, { name: "raw_long", - args: []string{"--raw", "-u"}, + args: []string{"--raw", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Raw) }, }, { name: "no_raw", - args: []string{"--no-raw", "-u"}, + args: []string{"--no-raw", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.NoRaw) }, @@ -324,14 +324,14 @@ func TestParse(t *testing.T) { }, { name: "color_always", - args: []string{"--color", "always", "-u"}, + args: []string{"--color", "always", "tar"}, check: func(t *testing.T, cli *CLI) { assert.Equal(t, "always", cli.Color) }, }, { name: "color_never", - args: []string{"--color", "never", "-u"}, + args: []string{"--color", "never", "tar"}, check: func(t *testing.T, cli *CLI) { assert.Equal(t, "never", cli.Color) }, @@ -352,21 +352,21 @@ func TestParse(t *testing.T) { }, { name: "edit", - args: []string{"--edit", "-u"}, + args: []string{"--edit", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Edit) }, }, { name: "short_options", - args: []string{"--short-options", "-u"}, + args: []string{"--short-options", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.ShortOptions) }, }, { name: "long_options", - args: []string{"--long-options", "-u"}, + args: []string{"--long-options", "tar"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.LongOptions) }, @@ -408,10 +408,9 @@ func TestParse(t *testing.T) { }, { name: "lint_with_all_options", - args: []string{"--lint", "file.md", "--in-place", "--tabular", "--ignore", "TLDR001"}, + args: []string{"--lint", "file.md", "--tabular", "--ignore", "TLDR001"}, check: func(t *testing.T, cli *CLI) { assert.True(t, cli.Lint) - assert.True(t, cli.InPlace) assert.True(t, cli.Tabular) assert.Equal(t, []string{"TLDR001"}, cli.Ignore) }, @@ -487,12 +486,28 @@ func TestParse(t *testing.T) { }, { name: "only_modifiers_no_operation", - args: []string{"--compact", "--edit", "--offline", "--no-raw"}, + args: []string{"--quiet", "--verbose"}, wantErr: true, errCheck: func(t *testing.T, err error) { assert.ErrorContains(t, err, "no operation specified") }, }, + { + name: "compact_without_parent", + args: []string{"--compact"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --compact requires a page or --render") + }, + }, + { + name: "edit_without_parent", + args: []string{"--edit"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --edit requires a page or --render") + }, + }, // error cases { @@ -527,6 +542,86 @@ func TestParse(t *testing.T) { assert.ErrorContains(t, err, "requires --format") }, }, + { + name: "in_place_with_lint", + args: []string{"--in-place", "--lint", "file.md"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --in-place requires --format") + }, + }, + { + name: "in_place_without_format", + args: []string{"--in-place", "file.md"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --in-place requires --format") + }, + }, + { + name: "tabular_without_lint_or_format", + args: []string{"--tabular", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --tabular requires --lint or --format") + }, + }, + { + name: "ignore_without_lint_or_format", + args: []string{"--ignore", "TLDR001", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --ignore requires --lint or --format") + }, + }, + { + name: "platform_with_update", + args: []string{"--platform", "linux", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --platform requires a page, --browse, --list or --search") + }, + }, + { + name: "offline_with_update", + args: []string{"--offline", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --offline requires a page or --browse") + }, + }, + { + name: "color_with_update", + args: []string{"--color", "always", "-u"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --color requires a page or --render") + }, + }, + { + name: "no_raw_with_search", + args: []string{"--no-raw", "-s", "ngi"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --no-raw requires a page or --render") + }, + }, + { + name: "language_with_list", + args: []string{"-L", "en", "-l"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --language requires a page, --browse, --search or --update") + }, + }, + { + name: "compact_with_search", + args: []string{"--compact", "-s", "ngi"}, + wantErr: true, + errCheck: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "flag --compact requires a page or --render") + }, + }, { name: "lint_and_format", args: []string{"--lint", "file.md", "--format", "file.md"}, diff --git a/cmd/validate.go b/cmd/validate.go index 1a5bb8f..a290e3f 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -30,24 +30,13 @@ func Validate(cli *CLI, fs *flag.FlagSet, args []string) (*CLI, error) { // validate checks that the parsed CLI has valid flags. func validate(cli *CLI) error { - switch cli.Color { - case "auto", "always", "never": - default: - return fmtUsage( - "invalid value for %s (expected %s, %s, %s)", - termcolor.Sprint("bold blue", "--color"), - termcolor.Sprint("blue", "auto"), - termcolor.Sprint("blue", "always"), - termcolor.Sprint("blue", "never"), - ) + if err := validateColor(cli); err != nil { + return err } // show version if cli.ShowVersion { - fmt.Printf( - "tlgc %s (implementing client specification v2.3)\n", - version.String(), - ) + fmt.Printf("tlgc %s (implementing client specification v2.3)\n", version.String()) return nil } @@ -57,19 +46,53 @@ func validate(cli *CLI) error { return nil } + // modifier flags require one of their parent operations + if err := validateFlagDependencies(cli); err != nil { + return err + } + // validate that exactly one operation is active - ops := cli.operationCount() - if ops == 0 { + if err := validateOperations(cli); err != nil { + return err + } + + return validateOperationArguments(cli) +} + +// validateColor validates the value of the --color flag. +func validateColor(cli *CLI) error { + switch cli.Color { + case "auto", "always", "never": + return nil + default: + return fmtUsage( + "invalid value for %s (expected %s, %s, %s)", + termcolor.Sprint("bold blue", "--color"), + termcolor.Sprint("blue", "auto"), + termcolor.Sprint("blue", "always"), + termcolor.Sprint("blue", "never"), + ) + } +} + +// validateOperations validates that the CLI specifies exactly one operation. +func validateOperations(cli *CLI) error { + switch ops := cli.operationCount(); { + case ops == 0: if cli.HasArgs { return fmtUsage("no operation specified") } help() return nil - } - if ops > 1 { + case ops > 1: return fmtConflictError(cli) + default: + return nil } +} +// validateOperationArguments validates the arguments required by the active operation. +func validateOperationArguments(cli *CLI) error { // browse requires a page if cli.Browse && len(cli.Page) == 0 { return fmtUsage( diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 0c7e003..9965548 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -97,6 +97,106 @@ func TestValidate(t *testing.T) { name: "output_with_format", cli: CLI{Color: "auto", Format: true, Output: "out.md", Page: []string{"file.md"}}, }, + { + name: "in_place_without_format", + cli: CLI{Color: "auto", InPlace: true, Page: []string{"file.md"}}, + wantErr: true, + }, + { + name: "in_place_with_format", + cli: CLI{Color: "auto", Format: true, InPlace: true, Page: []string{"file.md"}}, + }, + { + name: "tabular_with_lint", + cli: CLI{Color: "auto", Lint: true, Tabular: true, Page: []string{"file.md"}}, + }, + { + name: "tabular_with_format", + cli: CLI{Color: "auto", Format: true, Tabular: true, Page: []string{"file.md"}}, + }, + { + name: "tabular_alone", + cli: CLI{Color: "auto", HasArgs: true, Tabular: true}, + wantErr: true, + }, + { + name: "ignore_with_lint", + cli: CLI{Color: "auto", Lint: true, Ignore: []string{"TLDR001"}, Page: []string{"file.md"}}, + }, + { + name: "ignore_with_format", + cli: CLI{Color: "auto", Format: true, Ignore: []string{"TLDR001"}, Page: []string{"file.md"}}, + }, + { + name: "ignore_alone", + cli: CLI{Color: "auto", HasArgs: true, Ignore: []string{"TLDR001"}}, + wantErr: true, + }, + { + name: "platform_with_page", + cli: CLI{Color: "auto", Platform: "linux", Page: []string{"tar"}}, + }, + { + name: "platform_with_browse", + cli: CLI{Color: "auto", Platform: "linux", Browse: true, Page: []string{"tar"}}, + }, + { + name: "platform_with_list", + cli: CLI{Color: "auto", Platform: "linux", List: true}, + }, + { + name: "platform_with_search", + cli: CLI{Color: "auto", Platform: "linux", Search: "ngi"}, + }, + { + name: "platform_with_update", + cli: CLI{Color: "auto", Platform: "linux", Update: true}, + wantErr: true, + }, + { + name: "language_with_update", + cli: CLI{Color: "auto", Languages: []string{"en"}, Update: true}, + }, + { + name: "language_with_search", + cli: CLI{Color: "auto", Languages: []string{"en"}, Search: "ngi"}, + }, + { + name: "language_with_list", + cli: CLI{Color: "auto", Languages: []string{"en"}, List: true}, + wantErr: true, + }, + { + name: "offline_with_browse", + cli: CLI{Color: "auto", Offline: true, Browse: true, Page: []string{"tar"}}, + }, + { + name: "offline_with_update", + cli: CLI{Color: "auto", Offline: true, Update: true}, + wantErr: true, + }, + { + name: "compact_with_render", + cli: CLI{Color: "auto", Compact: true, Render: "file.md"}, + }, + { + name: "compact_with_search", + cli: CLI{Color: "auto", Compact: true, Search: "ngi"}, + wantErr: true, + }, + { + name: "edit_with_render", + cli: CLI{Color: "auto", Edit: true, Render: "file.md"}, + }, + { + name: "color_with_page", + cli: CLI{Color: "always", Page: []string{"tar"}}, + }, + { + name: "color_with_update", + cli: CLI{Color: "always", Update: true}, + wantErr: true, + }, { name: "valid_color_auto", cli: CLI{Color: "auto", Update: true}, @@ -104,12 +204,12 @@ func TestValidate(t *testing.T) { }, { name: "valid_color_always", - cli: CLI{Color: "always", Update: true}, + cli: CLI{Color: "always", Page: []string{"tar"}}, wantErr: false, }, { name: "valid_color_never", - cli: CLI{Color: "never", Update: true}, + cli: CLI{Color: "never", Page: []string{"tar"}}, wantErr: false, }, } @@ -126,6 +226,159 @@ func TestValidate(t *testing.T) { } } +func TestValidateColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + color string + wantErr bool + errString string + }{ + { + name: "auto", + color: "auto", + }, + { + name: "always", + color: "always", + }, + { + name: "never", + color: "never", + }, + { + name: "invalid", + color: "invalid", + wantErr: true, + errString: "invalid value for --color", + }, + { + name: "empty", + color: "", + wantErr: true, + errString: "invalid value for --color", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateColor(&CLI{Color: tt.color}) + if tt.wantErr { + assert.ErrorContains(t, err, tt.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateOperations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + errString string + }{ + { + name: "one_operation", + cli: CLI{HasArgs: true, Update: true}, + }, + { + name: "no_operations_with_args", + cli: CLI{HasArgs: true}, + wantErr: true, + errString: "no operation specified", + }, + { + name: "two_operations", + cli: CLI{HasArgs: true, Update: true, List: true}, + wantErr: true, + errString: "cannot be used with", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOperations(&tt.cli) + if tt.wantErr { + assert.ErrorContains(t, err, tt.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateOperationArguments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CLI + wantErr bool + errString string + }{ + { + name: "browse_with_page", + cli: CLI{Browse: true, Page: []string{"tar"}}, + }, + { + name: "browse_without_page", + cli: CLI{Browse: true}, + wantErr: true, + errString: "flag --browse requires a page", + }, + { + name: "lint_with_path", + cli: CLI{Lint: true, Page: []string{"pages/"}}, + }, + { + name: "lint_without_path", + cli: CLI{Lint: true}, + wantErr: true, + errString: "flag --lint requires a file or directory", + }, + { + name: "format_with_path", + cli: CLI{Format: true, Page: []string{"file.md"}}, + }, + { + name: "format_without_path", + cli: CLI{Format: true}, + wantErr: true, + errString: "flag --format requires a file or directory", + }, + { + name: "output_with_format", + cli: CLI{Format: true, Output: "out.md", Page: []string{"file.md"}}, + }, + { + name: "output_without_format", + cli: CLI{Output: "out.md", Page: []string{"file.md"}}, + wantErr: true, + errString: "flag --output requires --format", + }, + { + name: "no_operation", + cli: CLI{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOperationArguments(&tt.cli) + if tt.wantErr { + assert.ErrorContains(t, err, tt.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestOperationCount(t *testing.T) { t.Parallel() From 50bacbf6a718577ac1b0d8ac97f74561618ec0a4 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Mon, 17 Aug 2026 11:06:30 +0530 Subject: [PATCH 58/58] fix(app): Auto Update edge cases, Fixes #21 --- internal/app/app.go | 7 +++++ internal/app/info.go | 6 +++- internal/app/page.go | 25 ++-------------- internal/app/update.go | 19 ++++++++++++ internal/app/update_test.go | 59 +++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 internal/app/update_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 2faa45d..7292523 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -100,6 +100,13 @@ func (a *App) Run(cli *cmd.CLI) int { } } + if shouldAutoUpdate(cli) { + if err := a.autoUpdate(cli); err != nil { + logger.Error("failed to update cache: %v", err) + return 1 + } + } + return a.dispatch(cli) } diff --git a/internal/app/info.go b/internal/app/info.go index 7cb8149..de41c37 100644 --- a/internal/app/info.go +++ b/internal/app/info.go @@ -63,7 +63,11 @@ func (a *App) printCacheHeader(info *cache.InfoResult) error { // printAutoUpdate prints the automatic cache update configuration. func (a *App) printAutoUpdate(info *cache.InfoResult) error { if !info.AutoUpdate { - _, err := fmt.Fprintln(a.Stdout, "Auto update: disabled") + _, err := fmt.Fprintf( + a.Stdout, + "Auto update: %s\n", + termcolor.Sprint("bold red", "disabled"), + ) return err } diff --git a/internal/app/page.go b/internal/app/page.go index 31b7042..25ce076 100644 --- a/internal/app/page.go +++ b/internal/app/page.go @@ -1,7 +1,6 @@ package app import ( - "context" "fmt" "os" "path/filepath" @@ -12,7 +11,6 @@ import ( "github.com/TheRootDaemon/tlgc/internal/cache" "github.com/TheRootDaemon/tlgc/internal/config" "github.com/TheRootDaemon/tlgc/internal/render" - "github.com/TheRootDaemon/tlgc/internal/upstream" "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/pathutil" "github.com/TheRootDaemon/tlgc/termcolor" @@ -25,16 +23,6 @@ func (a *App) lookupAndRenderPage(cli *cmd.CLI) int { langs := a.resolveLanguages(cli.Languages) c := cache.New() - if !cli.Offline { - cfg := config.Cache() - if cfg.AutoUpdate && c.NeedsUpdate(cfg.MaxAge) { - client := upstream.New() - if err := c.Update(context.Background(), langs, client); err != nil { - logger.Warn("auto-update failed: %v", err) - } - } - } - query := strings.Join(cli.Page, "-") results, err := c.Find(query, p, langs) if err != nil { @@ -73,16 +61,6 @@ func (a *App) browsePage(cli *cmd.CLI) int { langs := a.resolveLanguages(cli.Languages) c := cache.New() - if !cli.Offline { - cfg := config.Cache() - if cfg.AutoUpdate && c.NeedsUpdate(cfg.MaxAge) { - client := upstream.New() - if err := c.Update(context.Background(), langs, client); err != nil { - logger.Warn("auto-update failed: %v", err) - } - } - } - query := strings.Join(cli.Page, "-") results, err := c.Find(query, p, langs) if err != nil { @@ -127,6 +105,9 @@ func (a *App) renderLocalFile(cli *cmd.CLI) int { return 0 } +// renderPage renders a parsed TLDR page +// to the terminal using the output options +// specified by the CLI. func (a *App) renderPage( cli *cmd.CLI, platform string, diff --git a/internal/app/update.go b/internal/app/update.go index 56dffc9..0bc6cae 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -23,3 +23,22 @@ func (a *App) updateCache(cli *cmd.CLI) int { } return 0 } + +// shouldAutoUpdate reports whether a stale cache maybe updated +// automatically for the current invocation. +// Automatic updates are disabled in offline mode +// and when explicit cache maintenance was requested. +func shouldAutoUpdate(cli *cmd.CLI) bool { + return !cli.Offline && !cli.Update && !cli.CleanCache +} + +// autoUpdate downloads the latest tldr-pages +// for the configured languages. +// It returns the error when the update fails. +func (a *App) autoUpdate(cli *cmd.CLI) error { + c := cache.New() + languages := a.resolveLanguages(cli.Languages) + client := upstream.New() + + return c.Update(context.Background(), languages, client) +} diff --git a/internal/app/update_test.go b/internal/app/update_test.go new file mode 100644 index 0000000..eee2697 --- /dev/null +++ b/internal/app/update_test.go @@ -0,0 +1,59 @@ +package app + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/cmd" + "github.com/stretchr/testify/assert" +) + +func TestShouldAutoUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli *cmd.CLI + want bool + }{ + { + name: "default_invocation", + cli: &cmd.CLI{}, + want: true, + }, + { + name: "offline_suppresses", + cli: &cmd.CLI{Offline: true}, + want: false, + }, + { + name: "explicit_update_suppresses", + cli: &cmd.CLI{Update: true}, + want: false, + }, + { + name: "clean_cache_suppresses", + cli: &cmd.CLI{CleanCache: true}, + want: false, + }, + { + name: "offline_beats_update", + cli: &cmd.CLI{ + Offline: true, + Update: true, + }, want: false, + }, + { + name: "offline_beats_clean", + cli: &cmd.CLI{ + Offline: true, + CleanCache: true, + }, want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, shouldAutoUpdate(tt.cli)) + }) + } +}