Skip to content

feat: add colorized option for highstate output - #68982

Open
mdschmitt wants to merge 5 commits into
saltstack:masterfrom
mdschmitt:colorized_diff_output
Open

feat: add colorized option for highstate output#68982
mdschmitt wants to merge 5 commits into
saltstack:masterfrom
mdschmitt:colorized_diff_output

Conversation

@mdschmitt

@mdschmitt mdschmitt commented Apr 22, 2026

Copy link
Copy Markdown

What does this PR do?

Adds optional colorization for diff chunks in the highstate output module.

disclaimer: I used AI to assist me in creating and testing this code, but it works appropriately as far as I can tell. The original version I had wasn't nearly as clean and smooth as this ended up.

I had some trouble getting all the pre-commit hooks to play nice on my Fedora laptop but I'm fairly sure that a workflow run will succeed. I'll obviously make changes if that's not the case.

What issues does this PR fix or reference?

I didn't submit an issue for this; I did however mention it a while back in Discord.
This is me attempting to upstream the polished final result.

Previous Behavior

All diff output in highstate output module was green regardless of additions/deletions

New Behavior

If specified, the full_color, changes_color and such output modes will show a colorized diff in the state return output (such as for file.managed). The results are colorized like so:

  • Added lines are green
  • Removed lines are red
  • Hunk headers are cyan
  • Surrounding context lines are standard white/gray.

Merge requirements satisfied?

[NOTICE] Bug fixes or features added to Salt require tests.

Commits signed with GPG?

No, but signed with ssh key (so it looks like GitHub itself re-signed it with gpg key ID B5690EEEBB952194)

@mdschmitt
mdschmitt requested a review from a team as a code owner April 22, 2026 11:17
@mdschmitt
mdschmitt force-pushed the colorized_diff_output branch 2 times, most recently from 10dc1af to a45beb9 Compare April 23, 2026 07:00
@dwoz
dwoz force-pushed the colorized_diff_output branch from a45beb9 to 8dea3a0 Compare June 12, 2026 23:44
@dwoz dwoz added the test:full Run the full test suite label Jun 12, 2026
@dwoz dwoz added this to the Potassium v3009.0 milestone Jun 13, 2026
@dwoz
dwoz force-pushed the colorized_diff_output branch from 9f479f5 to b5f906d Compare June 26, 2026 13:24
@twangboy

twangboy commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Thanks for putting this together. Having properly colorized unified diffs in the highstate output is a massive quality-of-life improvement for troubleshooting long state files.

However, looking closely at the implementation in salt/output/highstate.py, there are a few architectural gaps and potential regressions that we should address before this gets merged:

  1. Re-inventing the Wheel on Color Customization
    Inside both _render_diff and _render_changes_dict, the code completely re-initializes the color engine:
colors = salt.utils.color.get_colors(__opts__.get("color"), __opts__.get("color_theme"))

Because the highstate outputter runs these utility functions iteratively across every single state return block, pulling the entire terminal ANSI mapping from scratch adds unnecessary performance overhead (especially on large runs with hundreds of states or multiple minions).

The Fix: The main entry point _format_host() already instantiates the colors dictionary once at startup. We should simply pass that existing colors dictionary down into these utility helper functions as an argument.

  1. Bypassing the Native nested Outputter
    The introduction of a custom recursive dictionary layout engine (_render_changes_dict) bypasses Salt's native loader:
# Does not go through the Salt loader, so nested_indent is guaranteed to apply correctly 
regardless of Salt version.

Bypassing the standard nested outputter breaks standard user configuration setups (like custom nested_indent preferences, specific object serialization, and custom output templates). We should leverage Salt's pluggable architecture rather than duplicating a hardcoded string-builder that isolates the colorizer from future core output modifications.

  1. Mutating Mutable Objects Mid-Stream
    Using changes.pop("diff") and then putting it back alters the live data structure during output formatting. If downstream asynchronous hooks or master-side returners still hold a reference to that changes object down the line, temporary keys mutations can cause race conditions or obscure bugs.

Suggested Refactor Strategy
Instead of attempting to manually format the entire dictionary, a cleaner and safer approach is to use a surgical regex replacement wrapper. We can let Salt's native nested outputter format the dictionary structure exactly as it always does, and then selectively find and colorize just the text within the diff: block.

Here is how you can refactor it cleanly:

  1. Keep _render_diff focused (passing colors explicitly):
def _render_diff(diff_str, indent, colors):
    """
    Render a unified diff string with per-line ANSI colorization.
    """
    prefix = " " * indent
    if __opts__.get("color") is False:
        return "\n".join(prefix + line for line in diff_str.splitlines())

    GREEN = str(colors["GREEN"])
    ENDC = str(colors["ENDC"])
    RED = str(colors["RED"])
    CYAN = str(colors["CYAN"])
    WHITE = str(colors["LIGHT_GRAY"])
    LIGHT_RED = str(colors["LIGHT_RED"])
    
    result = []
    for line in diff_str.splitlines():
        if line.startswith("---"):
            color = LIGHT_RED
        elif line.startswith("+++"):
            color = GREEN
        elif line.startswith("@@"):
            color = CYAN
        elif line.startswith("+"):
            color = GREEN
        elif line.startswith("-"):
            color = RED
        else:
            color = WHITE
        result.append(f"{prefix}{color}{line}{ENDC}")
    return "\n".join(result)
  1. Rewrite _nested_changes_colorized as a Wrapper:

By running the standard nested outputter first, we capture the baseline format. We then parse out the exact block that belongs to the string-based diff using a regex pattern that respects whatever indentation depth was generated:

def _nested_changes_colorized(changes, colors):
    """
    Print the changes data with diff colorization by intercepting the standard
    nested outputter payload.
    """
    # 1. Get the baseline structure from the actual nested outputter
    nested_output = salt.output.out_format(changes, "nested", __opts__, nested_indent=14)
    
    # If there's no diff string to format, return it standard
    if not isinstance(changes, dict) or not isinstance(changes.get("diff"), str):
        return "\n" + nested_output

    # 2. Extract the raw diff block to figure out its runtime indentation dynamically
    # This matches 'diff:' and captures everything up until the next unindented sibling key
    diff_pattern = re.compile(r"^(\s*)diff:\n([\s\S]*?)(?=(?:^\s*[\w\d_.-]+:|\Z))", re.MULTILINE)
    match = diff_pattern.search(nested_output)
    
    if match:
        key_indent_str, raw_diff_content = match.groups()
        key_indent = len(key_indent_str)
        val_indent = key_indent + 4
        
        # Colorize just the diff payload line-by-line
        colorized_diff = _render_diff(changes["diff"], val_indent, colors)
        
        # Reconstruct the block with the colorized diff string
        CYAN = str(colors["CYAN"])
        ENDC = str(colors["ENDC"])
        colorized_block = f"{key_indent_str}{CYAN}diff:{ENDC}\n{colorized_diff}\n"
        
        # Swap the plain text block out for the colorized block safely
        nested_output = diff_pattern.sub(colorized_block, nested_output).rstrip()

    return "\n" + nested_output
  1. Update _format_changes Calls:
    Simply pass the existing colors mapping down when calling the colorized wrapper:
if colorize:
    return True, _nested_changes_colorized(changes, colors)

This keeps the engine highly performant, preserves standard loader behaviors for the structural output configuration, and eliminates mutable dictionary state manipulations. Let me know what you think!

@mdschmitt

mdschmitt commented Jul 17, 2026

Copy link
Copy Markdown
Author

@twangboy I changed a few things around and now it works even with file.recurse diff changes. I kinda don't trust myself with regex-fu so I went with a couple little helpers instead (except the one that helps parse out whitespace for the sub-key in file.recurse so it renders at the right depth). If you don't like it as-is, that's cool and I'll try again using the regex route you suggested.

Can you review and let me know what you think of this version?

Comment thread salt/output/highstate.py Outdated
mdschmitt and others added 4 commits July 27, 2026 15:08
Colorize unified diffs in the highstate outputter: added lines are green, removed lines are red, hunk headers are cyan, and context lines are gray.
`newfile:` and other change keys are rendered with the same indentation as the current functionality provides.
- Black reformat the lines.extend() generator call in _render_changes_dict
- Replace U+2192 arrows in _render_diff docstring with ASCII -> so the
  cp1252 docstring-encoding hook passes (these characters break salt-run -d
  and salt -d on Windows where stdout uses the locale-default encoding)
Replace the per-sentinel regex compile/scan loop in
_nested_changes_colorized with a single combined pattern that matches
every __COLORDIFF_N__ sentinel in one sweep, looking up each raw diff by
its captured id. Avoids repeated full-string scans (and potential layout
clobbering) when a state such as file.recurse produces multiple diffs, per
PR review feedback.
twangboy
twangboy previously approved these changes Jul 27, 2026
- Hoist the sentinel template and match pattern to module-level constants
  (_COLORDIFF_SENTINEL / _COLORDIFF_RE) instead of building the f-string in
  _assign_sentinel and recompiling the regex on every
  _nested_changes_colorized call, keeping the placeholder format defined in
  one place so producer and consumer can't drift apart.

- Neutralize embedded terminal escape sequences in the (untrusted) diff
  content in _render_diff before applying the colorization escape codes,
  matching the nested outputter's strip_colors behavior; the colorized path
  had been bypassing that protection.

- Render the +++ to-file header in bold green (LIGHT_GREEN) to mirror the
  bold red --- from-file header, and update the docstring, changelog, and
  CLI docs to match.

- Drop the _color handling from the orchestration branch of _format_changes,
  which is never reached (orchestration=True is not passed anywhere).

- Add a test for escape-sequence neutralization and asserts the file-header
colors in the shared helper.

- test_highstate.py used re.sub in _strip_ansi without importing re.

- _format_host built its color mapping straight from __opts__["color"],
  so a value of None produced empty color codes and silently disabled diff
  colorization. Treat None as "auto -> on" to fix the check in _render_diff.
@mdschmitt

mdschmitt commented Jul 28, 2026

Copy link
Copy Markdown
Author

Shoot, I did another run-through with a few more tweaks, can you take one more look please? My apologies for the force-push, I squashed some disparate commits into the most recent one and hadn't realized you already approved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants