diff --git a/internal/themes/themes.go b/internal/themes/themes.go index ef8de22bd..571bf0e57 100644 --- a/internal/themes/themes.go +++ b/internal/themes/themes.go @@ -239,13 +239,24 @@ func (s *Service) Remove(id string) error { } func (s *Service) customThemes() ([]Theme, error) { - entries, err := os.ReadDir(s.dir) + // Stat before reading: a file sitting where the themes directory belongs + // makes ReadDir report not-exist on Windows, which the "nothing imported + // yet" branch below would swallow into an empty list on that OS and an + // error everywhere else. + info, err := os.Stat(s.dir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("read themes dir: %w", err) } + if !info.IsDir() { + return nil, fmt.Errorf("themes path is not a directory: %s", s.dir) + } + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("read themes dir: %w", err) + } themes := make([]Theme, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { diff --git a/internal/themes/themes_test.go b/internal/themes/themes_test.go index ebee29ca0..171244266 100644 --- a/internal/themes/themes_test.go +++ b/internal/themes/themes_test.go @@ -577,6 +577,28 @@ func TestImportRejectsWindowsDeviceNames(t *testing.T) { } } +// \r? because the repo pins no .gitattributes, so a Windows checkout hands this +// test CRLF and a bare \n matches nothing there. +var docJSONBlock = regexp.MustCompile("(?s)```json\r?\n(.*?)```") + +func TestDocJSONBlockReadsBothLineEndings(t *testing.T) { + for _, tt := range []struct{ name, newline string }{ + {name: "lf", newline: "\n"}, + {name: "crlf", newline: "\r\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + doc := "text" + tt.newline + "```json" + tt.newline + "{}" + tt.newline + "```" + block := docJSONBlock.FindStringSubmatch(doc) + if block == nil { + t.Fatalf("no JSON block found in a %s document", tt.name) + } + if strings.TrimSpace(block[1]) != "{}" { + t.Fatalf("block = %q, want the JSON body", block[1]) + } + }) + } +} + // The documented example is the shape users copy from, so it answers to the // validator like any other imported theme. func TestDocumentedExampleIsImportable(t *testing.T) { @@ -584,7 +606,7 @@ func TestDocumentedExampleIsImportable(t *testing.T) { if err != nil { t.Fatalf("read docs/themes.md: %v", err) } - block := regexp.MustCompile("(?s)```json\n(.*?)```").FindSubmatch(data) + block := docJSONBlock.FindSubmatch(data) if block == nil { t.Fatal("docs/themes.md has no JSON example") }