Skip to content

Commit

Permalink
Colorize Atmos Describe Commands when TTY attached (#919)
Browse files Browse the repository at this point in the history
* tty highlight colors rebuild

* lexer fixes and improvements

* added general fixes and refactoring

* flag detection

* updates mdx

* updates mdx

* update theme and docs

* added theme

* refactor pager

* improvements refactoring pager

* remove timestamps

---------

Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]>
Co-authored-by: Andriy Knysh <[email protected]>
  • Loading branch information
3 people authored Jan 16, 2025
1 parent 0a75010 commit 20098bf
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 20 deletions.
16 changes: 11 additions & 5 deletions atmos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,17 @@ settings:

# Terminal settings for displaying content
terminal:
max_width: 120 # Maximum width for terminal output
pager: true # Use pager for long output
timestamps: false # Show timestamps in logs
colors: true # Enable colored output
unicode: true # Use unicode characters
max_width: 120 # Maximum width for terminal output
pager: true # Pager setting for all terminal output
colors: true # Enable colored output
unicode: true # Use unicode characters

syntax_highlighting:
enabled: true
formatter: terminal # Output formatter (e.g., terminal, html)
theme: dracula # Highlighting theme
line_numbers: true # Display line numbers
wrap: false # Wrap long lines

# Markdown element styling
markdown:
Expand Down
8 changes: 4 additions & 4 deletions cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@ var docsCmd = &cobra.Command{
u.LogErrorAndExit(schema.AtmosConfiguration{}, err)
}

usePager := atmosConfig.Settings.Terminal.Pager
if !usePager && atmosConfig.Settings.Docs.Pagination {
usePager = atmosConfig.Settings.Docs.Pagination
pager := atmosConfig.Settings.Terminal.Pager
if !pager && atmosConfig.Settings.Docs.Pagination {
pager = atmosConfig.Settings.Docs.Pagination
u.LogWarning(atmosConfig, "'settings.docs.pagination' is deprecated and will be removed in a future version. Please use 'settings.terminal.pager' instead")
}

if err := u.DisplayDocs(componentDocs, usePager); err != nil {
if err := u.DisplayDocs(componentDocs, pager); err != nil {
u.LogErrorAndExit(schema.AtmosConfiguration{}, fmt.Errorf("failed to display documentation: %w", err))
}

Expand Down
6 changes: 3 additions & 3 deletions internal/tui/templates/templater.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ func SetCustomUsageFunc(cmd *cobra.Command) error {
return nil
}

// getTerminalWidth returns the width of the terminal, defaulting to 80 if it cannot be determined
func getTerminalWidth() int {
// GetTerminalWidth returns the width of the terminal, defaulting to 80 if it cannot be determined
func GetTerminalWidth() int {
defaultWidth := 80
screenWidth := defaultWidth

Expand All @@ -156,7 +156,7 @@ func getTerminalWidth() int {
// WrappedFlagUsages formats the flag usage string to fit within the terminal width
func WrappedFlagUsages(f *pflag.FlagSet) string {
var builder strings.Builder
width := getTerminalWidth()
width := GetTerminalWidth()
printer, err := NewHelpFlagPrinter(&builder, uint(width), f)
if err != nil {
// If we can't create the printer, return empty string
Expand Down
18 changes: 18 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/pkg/errors"
"github.com/spf13/viper"

"github.com/cloudposse/atmos/internal/tui/templates"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui/theme"
u "github.com/cloudposse/atmos/pkg/utils"
Expand Down Expand Up @@ -53,6 +54,23 @@ var (
UseEKS: true,
},
},
Settings: schema.AtmosSettings{
ListMergeStrategy: "replace",
Terminal: schema.Terminal{
MaxWidth: templates.GetTerminalWidth(),
Pager: true,
Colors: true,
Unicode: true,
SyntaxHighlighting: schema.SyntaxHighlighting{
Enabled: true,
Formatter: "terminal",
Theme: "dracula",
HighlightedOutputPager: true,
LineNumbers: true,
Wrap: false,
},
},
},
Workflows: schema.Workflows{
BasePath: "stacks/workflows",
},
Expand Down
20 changes: 15 additions & 5 deletions pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,21 @@ type AtmosConfiguration struct {
}

type Terminal struct {
MaxWidth int `yaml:"max_width" json:"max_width" mapstructure:"max_width"`
Pager bool `yaml:"pager" json:"pager" mapstructure:"pager"`
Timestamps bool `yaml:"timestamps" json:"timestamps" mapstructure:"timestamps"`
Colors bool `yaml:"colors" json:"colors" mapstructure:"colors"`
Unicode bool `yaml:"unicode" json:"unicode" mapstructure:"unicode"`
MaxWidth int `yaml:"max_width" json:"max_width" mapstructure:"max_width"`
Pager bool `yaml:"pager" json:"pager" mapstructure:"pager"`
Colors bool `yaml:"colors" json:"colors" mapstructure:"colors"`
Unicode bool `yaml:"unicode" json:"unicode" mapstructure:"unicode"`
SyntaxHighlighting SyntaxHighlighting `yaml:"syntax_highlighting" json:"syntax_highlighting" mapstructure:"syntax_highlighting"`
}

type SyntaxHighlighting struct {
Enabled bool `yaml:"enabled" json:"enabled" mapstructure:"enabled"`
Lexer string `yaml:"lexer" json:"lexer" mapstructure:"lexer"`
Formatter string `yaml:"formatter" json:"formatter" mapstructure:"formatter"`
Theme string `yaml:"theme" json:"theme" mapstructure:"theme"`
HighlightedOutputPager bool `yaml:"pager" json:"pager" mapstructure:"pager"`
LineNumbers bool `yaml:"line_numbers" json:"line_numbers" mapstructure:"line_numbers"`
Wrap bool `yaml:"wrap" json:"wrap" mapstructure:"wrap"`
}

type AtmosSettings struct {
Expand Down
17 changes: 17 additions & 0 deletions pkg/utils/config_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package utils

import "github.com/cloudposse/atmos/pkg/schema"

// ExtractAtmosConfig extracts the Atmos configuration from any data type.
// It handles both direct AtmosConfiguration instances and pointers to AtmosConfiguration.
// If the data is neither, it returns an empty configuration.
func ExtractAtmosConfig(data any) schema.AtmosConfiguration {
switch v := data.(type) {
case schema.AtmosConfiguration:
return v
case *schema.AtmosConfiguration:
return *v
default:
return schema.AtmosConfiguration{}
}
}
192 changes: 192 additions & 0 deletions pkg/utils/highlight_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package utils

import (
"bytes"
"io"
"os"
"strings"

"encoding/json"

"github.com/alecthomas/chroma"
"github.com/alecthomas/chroma/formatters"
"github.com/alecthomas/chroma/lexers"
"github.com/alecthomas/chroma/quick"
"github.com/alecthomas/chroma/styles"
"github.com/cloudposse/atmos/internal/tui/templates"
"github.com/cloudposse/atmos/pkg/schema"
"golang.org/x/term"
)

// DefaultHighlightSettings returns the default syntax highlighting settings
func DefaultHighlightSettings() *schema.SyntaxHighlighting {
return &schema.SyntaxHighlighting{
Enabled: true,
Formatter: "terminal",
Theme: "dracula",
HighlightedOutputPager: true,
LineNumbers: true,
Wrap: false,
}
}

// GetHighlightSettings returns the syntax highlighting settings from the config or defaults
func GetHighlightSettings(config schema.AtmosConfiguration) *schema.SyntaxHighlighting {
defaults := DefaultHighlightSettings()
if config.Settings.Terminal.SyntaxHighlighting == (schema.SyntaxHighlighting{}) {
return defaults
}
settings := &config.Settings.Terminal.SyntaxHighlighting
// Apply defaults for any unset fields
if !settings.Enabled {
settings.Enabled = defaults.Enabled
}
if settings.Formatter == "" {
settings.Formatter = defaults.Formatter
}
if settings.Theme == "" {
settings.Theme = defaults.Theme
}
if !settings.HighlightedOutputPager {
settings.HighlightedOutputPager = defaults.HighlightedOutputPager
}
if !settings.LineNumbers {
settings.LineNumbers = defaults.LineNumbers
}
if !settings.Wrap {
settings.Wrap = defaults.Wrap
}
return settings
}

// HighlightCode highlights the given code using chroma with the specified lexer and theme
func HighlightCode(code string, lexerName string, theme string) (string, error) {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return code, nil
}
var buf bytes.Buffer
err := quick.Highlight(&buf, code, lexerName, "terminal", theme)
if err != nil {
return code, err
}
return buf.String(), nil
}

// HighlightCodeWithConfig highlights the given code using the provided configuration
func HighlightCodeWithConfig(code string, config schema.AtmosConfiguration, format ...string) (string, error) {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return code, nil
}
settings := GetHighlightSettings(config)
if !settings.Enabled {
return code, nil
}

// Get terminal width
config.Settings.Terminal.MaxWidth = templates.GetTerminalWidth()

// Determine lexer based on format flag or content format
var lexerName string
if len(format) > 0 && format[0] != "" {
// Use format flag if provided
lexerName = strings.ToLower(format[0])
} else {
// This is just a fallback
trimmed := strings.TrimSpace(code)

// Try to parse as JSON first
if json.Valid([]byte(trimmed)) {
lexerName = "json"
} else {
// Check for common YAML indicators
// 1. Contains key-value pairs with colons
// 2. Does not start with a curly brace (which could indicate malformed JSON)
// 3. Contains indentation or list markers
if (strings.Contains(trimmed, ":") && !strings.HasPrefix(trimmed, "{")) ||
strings.Contains(trimmed, "\n ") ||
strings.Contains(trimmed, "\n- ") {
lexerName = "yaml"
} else {
// Fallback to plaintext if format is unclear
lexerName = "plaintext"
}
}
}

// Get lexer
lexer := lexers.Get(lexerName)
if lexer == nil {
lexer = lexers.Fallback
}
// Get style
s := styles.Get(settings.Theme)
if s == nil {
s = styles.Fallback
}
// Get formatter
var formatter chroma.Formatter
if settings.LineNumbers {
formatter = formatters.TTY256
} else {
formatter = formatters.Get(settings.Formatter)
if formatter == nil {
formatter = formatters.Fallback
}
}
// Create buffer for output
var buf bytes.Buffer
// Format the code
iterator, err := lexer.Tokenise(nil, code)
if err != nil {
return code, err
}
err = formatter.Format(&buf, s, iterator)
if err != nil {
return code, err
}
return buf.String(), nil
}

// HighlightWriter returns an io.Writer that highlights code written to it
type HighlightWriter struct {
config schema.AtmosConfiguration
writer io.Writer
format string
}

// NewHighlightWriter creates a new HighlightWriter
func NewHighlightWriter(w io.Writer, config schema.AtmosConfiguration, format ...string) *HighlightWriter {
var f string
if len(format) > 0 {
f = format[0]
}
return &HighlightWriter{
config: config,
writer: w,
format: f,
}
}

// Write implements io.Writer
// The returned byte count n is the length of p regardless of whether the highlighting
// process changes the actual number of bytes written to the underlying writer.
// This maintains compatibility with the io.Writer interface contract while still
// providing syntax highlighting functionality.
func (h *HighlightWriter) Write(p []byte) (n int, err error) {
highlighted, err := HighlightCodeWithConfig(string(p), h.config, h.format)
if err != nil {
return 0, err
}

// Write the highlighted content, ignoring the actual number of bytes written
// since we'll return the original input length
_, err = h.writer.Write([]byte(highlighted))
if err != nil {
// If there's an error, we can't be sure how many bytes were actually written
return 0, err
}

// Return the original length of p as required by io.Writer interface
// This ensures that the caller knows all bytes from p were processed
return len(p), nil
}
9 changes: 8 additions & 1 deletion pkg/utils/json_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ func PrintAsJSON(data any) error {
return err
}

PrintMessage(prettyJSON.String())
atmosConfig := ExtractAtmosConfig(data)
highlighted, err := HighlightCodeWithConfig(prettyJSON.String(), atmosConfig)
if err != nil {
// Fallback to plain text if highlighting fails
PrintMessage(prettyJSON.String())
return nil
}
PrintMessage(highlighted)
return nil
}

Expand Down
10 changes: 9 additions & 1 deletion pkg/utils/yaml_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ func PrintAsYAML(data any) error {
if err != nil {
return err
}
PrintMessage(y)

atmosConfig := ExtractAtmosConfig(data)
highlighted, err := HighlightCodeWithConfig(y, atmosConfig)
if err != nil {
// Fallback to plain text if highlighting fails
PrintMessage(y)
return nil
}
PrintMessage(highlighted)
return nil
}

Expand Down
1 change: 0 additions & 1 deletion website/docs/cli/configuration/markdown-styling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ settings:
terminal:
max_width: 120 # Maximum width for terminal output
pager: true # Use pager for long output
timestamps: false
colors: true
unicode: true

Expand Down
Loading

0 comments on commit 20098bf

Please sign in to comment.