diff --git a/.gitignore b/.gitignore index bc29e99..efcfdf5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ !test/golden/**/*.png !examples/*.svg /result +freeze diff --git a/README.md b/README.md index 8486b44..5e5bfe0 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Screenshots can be customized with `--flags` or [Configuration](#configuration) > [!NOTE] > You can view all freeze customization with `freeze --help`. -- [`-o`](#output), [`--output`](#output): Output location for .svg, .png, .jpg. +- [`-o`](#output), [`--output`](#output): Output location for .svg, .png, .jpg, use - for stdout. - [`-c`](#configuration), [`--config`](#configuration): Base configuration file or template. - [`-t`](#theme), [`--theme`](#theme): Theme to use for syntax highlighting. - [`-l`](#language), [`--language`](#language): Language to apply to code @@ -154,6 +154,12 @@ freeze main.go --output out.webp freeze main.go --output out.{svg,png,webp} ``` +Have `freeze` send image data to stdout by passing `-` for `--output` + +```bash +freeze main.go --output - +``` + ### Font Specify the font family, font size, and font line height of the output image. diff --git a/config.go b/config.go index 09448f0..26ccf5d 100644 --- a/config.go +++ b/config.go @@ -31,7 +31,8 @@ type Config struct { Language string `json:"language,omitempty" help:"Language of code file." short:"l" group:"Settings" placeholder:"go"` Theme string `json:"theme" help:"Theme to use for syntax highlighting." short:"t" group:"Settings" placeholder:"charm"` - Output string `json:"output,omitempty" help:"Output location for {{.svg}}, {{.png}}, or {{.webp}}." short:"o" group:"Settings" default:"" placeholder:"freeze.svg"` + Output string `json:"output,omitempty" help:"Output location for {{.svg}}, {{.png}}, or {{.webp}}, use - for stdout." short:"o" group:"Settings" default:"" placeholder:"freeze.svg"` + Format string `json:"format,omitempty" help:"Output format for file, {{.svg}}, {{.png}} or {{.webp}}." short:"f" group:"Settings" default:"svg" placeholder:"svc"` Execute string `json:"-" help:"Capture output of command execution." short:"x" group:"Settings" default:""` ExecuteTimeout time.Duration `json:"-" help:"Execution timeout." group:"Settings" default:"10s" prefix:"execute." name:"timeout" hidden:""` diff --git a/freeze_test.go b/freeze_test.go index 5409a9f..dde2f92 100644 --- a/freeze_test.go +++ b/freeze_test.go @@ -4,6 +4,7 @@ import ( "bytes" "flag" "fmt" + "io" "os" "os/exec" "strings" @@ -40,15 +41,58 @@ func TestFreeze(t *testing.T) { } } +// freeze is a wrapper around calling the binary itself +func freeze(input, output string, inputRead io.Reader, outputWrite io.Writer) error { + args := []string{} + + if input != "" { + args = append(args, input) + } + + if output != "" { + args = append(args, "-o", output) + } + + cmd := exec.Command(binary, args...) + + if inputRead != nil { + cmd.Stdin = inputRead + } + + if outputWrite != nil { + cmd.Stdout = outputWrite + } + + return cmd.Run() +} + func TestFreezeOutput(t *testing.T) { output := "artichoke-test.svg" defer os.Remove(output) - cmd := exec.Command(binary, "test/input/artichoke.hs", "-o", output) - err := cmd.Run() + if err := freeze("test/input/artichoke.hs", output, nil, nil); err != nil { + t.Fatal(err) + } + + _, err := os.Stat(output) if err != nil { t.Fatal(err) } +} + +// read file from stdin +func TestFreezeOutputWhenInputStdin(t *testing.T) { + output := "tab.svg" + defer os.Remove(output) + + inputMock, err := os.Open("test/input/tab.go") + if err != nil { + t.Fatal(err) + } + + if err := freeze("-", output, inputMock, nil); err != nil { + t.Fatal(err) + } _, err = os.Stat(output) if err != nil { @@ -56,6 +100,44 @@ func TestFreezeOutput(t *testing.T) { } } +// fall back to default filename +func TestFreezeOutputWhenNoOutputFilename(t *testing.T) { + defer os.Remove(defaultOutputFilename) + + if err := freeze("test/input/tab.go", "", nil, nil); err != nil { + t.Fatal(err) + } + + _, err := os.Stat(defaultOutputFilename) + if err != nil { + t.Fatal(err) + } +} + +func TestFreezeOutputWhenOutputStdout(t *testing.T) { + stdoutMock := "free_stdout.svg" + + f, err := os.OpenFile(stdoutMock, os.O_CREATE|os.O_WRONLY, 0750) + if err != nil { + t.Fatal(err) + } + + defer os.Remove(stdoutMock) + + if err := freeze("test/input/tab.go", "-", nil, f); err != nil { + t.Fatal(err) + } + + i, err := os.Stat(stdoutMock) + if err != nil { + t.Fatal(err) + } + + if i.Size() == 0 { + t.Fatal("nothing was writtent to stdout") + } +} + func TestFreezeHelp(t *testing.T) { out := bytes.Buffer{} cmd := exec.Command(binary) diff --git a/main.go b/main.go index 3675a47..361b6d5 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,8 @@ var ( CommitSHA = "" ) +var istty = isatty.IsTerminal(os.Stdout.Fd()) + func main() { const shaLen = 7 @@ -386,15 +388,19 @@ func main() { } } - istty := isatty.IsTerminal(os.Stdout.Fd()) + format, err := getFormat(&config) + if err != nil { + printErrorFatal("Invalid output format", err) + } - switch { - case strings.HasSuffix(config.Output, ".png"): + output := getOutputFilename(&config, format) + + if format == "png" { // use libsvg conversion. - svgConversionErr := libsvgConvert(doc, imageWidth, imageHeight, config.Output) + svgConversionErr := libsvgConvert(doc, imageWidth, imageHeight, output) if svgConversionErr == nil { - printFilenameOutput(config.Output) - break + printFilenameOutput(output) + return } // could not convert with libsvg, try resvg @@ -402,49 +408,66 @@ func main() { if svgConversionErr != nil { printErrorFatal("Unable to convert SVG to PNG", svgConversionErr) } - printFilenameOutput(config.Output) - - default: - // output file specified. - if config.Output != "" { - err = doc.WriteToFile(config.Output) - if err != nil { - printErrorFatal("Unable to write output", err) - } - printFilenameOutput(config.Output) - return - } - // reading from stdin. - if config.Input == "" || config.Input == "-" { - if istty { - err = doc.WriteToFile(defaultOutputFilename) - printFilenameOutput(defaultOutputFilename) - } else { - _, err = doc.WriteTo(os.Stdout) - } - if err != nil { - printErrorFatal("Unable to write output", err) - } - return - } + printFilenameOutput(output) - // reading from file. - if istty { - config.Output = strings.TrimSuffix(filepath.Base(config.Input), filepath.Ext(config.Input)) + ".svg" - err = doc.WriteToFile(config.Output) - printFilenameOutput(config.Output) - } else { - _, err = doc.WriteTo(os.Stdout) - } - if err != nil { - printErrorFatal("Unable to write output", err) - } + return } + + err = doc.WriteToFile(output) + if err != nil { + printErrorFatal("Unable to write output", err) + } + + printFilenameOutput(output) } var outputHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1F1F1")).Background(lipgloss.Color("#6C50FF")).Bold(true).Padding(0, 1).MarginRight(1).SetString("WROTE") func printFilenameOutput(filename string) { + if !istty || filename != "-" { + return + } + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Center, outputHeader.String(), filename)) } + +func isAllowedFormat(format string) error { + if format == "svg" || format == "png" || format == "webp" { + return nil + } + + return fmt.Errorf("%s is not a supported output file format, choose among png, svg and webp", format) +} + +func getFormat(config *Config) (string, error) { + if config.Format != "" { + return config.Format, isAllowedFormat(config.Format) + } + + ext := filepath.Ext(config.Output) + + return ext, isAllowedFormat(ext) +} + +func getOutputFilename(config *Config, format string) string { + // always write to file if specified + if config.Output != "" && config.Output != "-" { + return config.Output + } + + if !istty || config.Output == "-" { + return os.Stdout.Name() + } + + // no input filename AND no output filename + // no way to "deduce" the name + if config.Input == "" || config.Input == "-" { + return defaultOutputFilename + } + + // no output filename, but some input filename + // now we need the format + // remove existing extension for the format of output + return strings.TrimSuffix(filepath.Base(config.Input), filepath.Ext(config.Input)) + "." + format +}