lukeed / kleur

The fastest Node.js library for formatting terminal text with ANSI colors~!

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Unexpected reset behavior

jaydenseric opened this issue · comments

With this code:

const kleur = require('kleur')

console.log(kleur.red(kleur.reset('a' + kleur.blue('b') + 'c')))

The result is that the last character logged is unexpectedly red, when it should be default (black):

Screen Shot 2021-01-21 at 6 33 04 pm

Hey,

Actually no, it should be red. The last thing your chain does is wrap the content in the red ANSI codes and the reset ANSI code has no state. It may help to view as a JSX/HTML diagram:

<Red>
  <Reset/>
  a
  <Blue>b</Blue>
  c
</Red>

In reality, the <Reset/> is duplicated after the closing Blue tag... but so is the Red opening tag because it's still the parent state / unterminated in the stack.

FWIW, Chalk produces the same output.

c = chalk.red(chalk.reset('a' + chalk.blue('b') + 'c'))
//=> '\x1B[31m\x1B[0ma\x1B[34mb\x1B[39m\x1B[31mc\x1B[0m\x1B[39m'

k = kleur.red(kleur.reset('a' + kleur.blue('b') + 'c'))
//=> '\x1B[31m\x1B[0ma\x1B[34mb\x1B[39m\x1B[31mc\x1B[0m\x1B[39m'

Both, when logged:
Screen Shot 2021-01-21 at 9 27 47 AM

Hope that helps!

@lukeed please do a "JSX/HTML diagram" for this one:

console.log(kleur.red(kleur.reset('XXXXXXXXX')))

Screen Shot 2021-01-22 at 9 16 16 am

Now for this one:

console.log(kleur.red(kleur.reset('XXXXXX' + kleur.blue('X') + 'XX')))

Screen Shot 2021-01-22 at 9 18 01 am

Why are the last 2 XX red? This is clearly unexpected. Please consider bugs from the perspective of a user and what a reasonable person would expect to work or needs to work. Don't just figure out the implementation details for the bug's existence and then close the issue saying "well, of course it works that way!".

FWIW, Chalk produces the same output.

I know, I already tested the chalk behavior regarding this bug. It's not perfect software; as a lower priority I was going to raise an issue there after understanding the problem better here since this is the software I actually need to work.

Being able to reset the styles deep inside a string is pretty important. In my use case, I have in a CLI errors being logged with their messages wrapped in kleur.red(). One kind of messages has at the end a syntax highlighted code frame displaying the point in code an error occurred. I need the ability to reset any parent styling for the contents of the code frame so as to not interfere with the syntax highlighting.

If there is a hard technical reason kleur.reset() could never work as expected nested, like all the other colors and formatting, that needs to be caveated in its documentation in the readme. I'm not convinced this bug is not solvable though; I'm pretty sure it would be possible to get the expected outcome with manually written ANSI codes.

Your issue is with ANSI codes in general. Reset is the only one that is not "stateful" – yet to maintain the same API, it appears as though it does wrap, but it doesn't. The presence of \x1b[0 resets all styling following the rest of the string -- unlike all other colors, which take effect until its relevant "close" is found.

This poses a problem because close-codes are shared. In a wrapper-style API, this means that your RED ends abruptly because your BLUE ended:

Screen Shot 2021-01-21 at 6 14 06 PM

console.log('\x1b[31mRED\x1b[34mBLUE\x1b[39mRED?\x1b[39m')

Because of this, you have to search the string & reopen RED after BLUE closes to continue:

Screen Shot 2021-01-21 at 6 14 48 PM

console.log('\x1b[31mRED\x1b[34mBLUE\x1b[39m\x1b[31mRED?\x1b[39m')

Unfortunately, this has to be done for every matching {close-code} instance within the current string in order for it to safely continue. You can't "cheat" and only fix-up the last occurrence of {close-code} because there may be text that was meant to inherit a color in between two tags; for example:

kleur.red(`${kleur.blue('FOO')} BAR ${kleur.yellow('BAZ')} BAT`);
//=> "\u001b[31m\u001b[34mFOO\u001b[39m\u001b[31m BAR \u001b[33mBAZ\u001b[39m\u001b[31m BAT\u001b[39m"

Screen Shot 2021-01-21 at 6 24 24 PM

Why am I bothering with this?
Because the same thing is happening in your examples.

Blue and Red share a closing tag. When blue is closing, red has to reopen itself because it's the parent/external state:

kleur.red(
  kleur.reset('RRR' + kleur.blue('BBB') + 'OOO')
) //=> "\u001b[31m\u001b[0mRRR\u001b[34mBBB\u001b[39m\u001b[31mOOO\u001b[0m\u001b[39m"
//          +RED       +RESET       +BLUE       -BLUE     +RED      "-RESET"     -RED

// What kleur produces, in stages:
// 1) "\u001b[34mBBB\u001b[39m"
// 2) "\u001b[0mRRR\u001b[34mBBB\u001b[39mOOO\u001b[0m"
// 3) "\u001b[31m\u001b[0mRRR\u001b[34mBBB\u001b[39m\u001b[31mOOO\u001b[0m\u001b[39m" (final)

Again, what you "want" is for RESET to be stateful. If it were, then OOO would reside within the RESET fences & behave as you expect. However, it does not do this at the ANSI level – and it's why this behavior supersedes JS and exists in every colorizer w/ a wrapping API that I've used in other languages.

If you can, it's always much better to concatenate a final string instead of using kleur (or any) colorizer N layers deep. As you can see in the FOO BAR BAZ BAT example, it actually looks quite silly – and produces a super wasteful output. Something like the following is definitely preferred (and is why there aren't too many wrapper-APIs in other langs) as it's less wasteful and yields more control over your output:

kleur.reset('RRR') + kleur.blue('BBB') + kleur.reset('OOO');
// => intentionally no red here

You can also substitute all reset() in this example w/ a call to strip all ANSI codes from the string. While slower, it skips over the append-only behavior of kleur.reset in favor of removing all other ANSI breadcrumbs, which would otherwise be seen/parsed by parent kleur calls:

function strip(str) {
  return str.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g, '');
}

Don't just figure out the implementation details for the bug's existence and then close the issue saying "well, of course it works that way!".

This is sad & I hope you don't actually mean this... The design & implementation came first w/ careful consideration. Following Chalk was intentional so as to make kleur a drop-in, faster replacement. Not following Chalk would cause far more issues because people already have set expectations. And given that reset is different at the ANSI level, I knowingly accepted this edge case (especially since it was already "normalized" via Chalk). Finally, I thought about using the strip implementation as the kleur.reset behavior, but that would be worse since it'd prevent all other functions from being nested inside a reset().

I closed this because it's not unexpected. While it may be unfortunate, it's working as designed and is the best-fit for a wrapper API and the ANSI landscape.

Hope that maybe helps more than last time

Hope that maybe helps more than last time

Thank you, I appreciate the considerable detail in your comment. I apologise though as I'm struggling to grok all of the explanation.

The ANSI reset code doesn't have a related end code, but do we need it to? Can't nested color calls only restore the parent formatting if there is no reset code before it at the same level?

HTML doesn't need end tags for some things, like <li>. It can automatically close the last one when it encounters the next one:

https://www.w3.org/TR/2014/REC-html5-20141028/syntax.html#optional-tags

Could a similar concept be applied to an implementation here for reset nesting?

If there truly is no solution, then kleur.reset() should probably not be a chainable/nestable function like the other color and formatting options. It should just be removed from the API, or perhaps exist as a standalone function or constant for manual insertion. Is the rule that kleur.reset() can be nested inside other kleur functions, but not the other way around? Could misuse be detected and throw an error?

You're welcome. I'll try a different way, without HTML references because it doesn't actually share any behaviors :) I chose it on a whim just to show the nesting hierarchy, but I think the inline comments might be better.

So, RESET (when caps, refers to ANSI) terminates all style codes when encountered. For this reason, it has no close-code pairing. This makes it unique from the others in that everything else is "apply until X" whereas RESET is just "apply". In other words, RED will continue applying RED until its assigned close-code is found, BOLD still applies BOLD until closed. RESET doesn't have an "until" clause. It applies at that point, immediately closing all previously-open windows:

k.red(' RED ' + k.reset(' RESET ') + ' RED?' );
//=> '\x1B[31m RED \x1B[0m RESET \x1B[0m RED?\x1B[39m'
//=> '\x1B[31m RED \x1B[0m RESET \x1B[0m RED?\x1B[39m'
//=>      +R         RESET        RESET         -R
// (effect)           -R

However, ANSI codes of the same type share close codes. Because of this, when nesting colors, your RED has to re-initialize itself on the back of any close-code occurrences it finds within the payload. If it doesn't, then RED terminates once the other color does too:

k.red('RED ' + k.blue('BLUE') + ' RED?')
// CURRENT (Correct)
// => '\x1B[31mRED \x1B[34mBLUE\x1B[39m\x1B[31m RED?\x1B[39m'
// =>      +R          +B        -B|-R     +R           -R

// WITHOUT REOPEN (Incorrect)
// => '\x1B[31mRED \x1B[34mBLUE\x1B[39m RED?\x1B[39m'
// =>      +R          +B        -B|-R          -R (R not active)

If we look back at the original issue (I only changed text for visibility) and the step-by-step, now with diagrams:

k.red(
  k.reset(' XXX ' + k.blue(' YYY ') + ' ZZZ ')
);

// What kleur produces, in stages:
// 1)                     "\x1B[34m YYY \x1B[39m"
//                              +B          -B
// 2)         "\x1B[0m XXX \x1B[34m YYY \x1B[39m ZZZ \x1B[0m"
//                RESET         +B          -B         RESET
// 3) "\x1B[31m\x1B[0m XXX \x1B[34m YYY \x1B[39m\x1B[31m ZZZ \x1B[0m\x1B[39m" (final)
//          +R    RESET         +B          -B|-R    +R         RESET   -R
//                 -R                                ^ADDED      -R

We see the same behavior happening. Between (2) and (3), kleur.red is inheriting a payload that already contains the [39m that would close RED as well as the BLUE. Because of that, RED re-inserts itself so as to not break (like the first example). Now, this means that +R is added immediately after the -B|-R – but this so happens to be before the ZZZ text too.

It might be helpful to see the same chain, but replacing RESET with another non-color code to be alongside RED/BLUE:

k.red(
  k.underline(' XXX ' + k.blue(' YYY ') + ' ZZZ ')
);

// What kleur produces, in stages:
// 1)                     "\x1B[34m YYY \x1B[39m"
//                              +B          -B
// 2)         "\x1B[4m XXX \x1B[34m YYY \x1B[39m ZZZ \x1B[24m"
//                 +U           +B          -B           -U
// 3) "\x1B[31m\x1B[4m XXX \x1B[34m YYY \x1B[39m\x1B[31m ZZZ \x1B[24m\x1B[39m" (final)
//          +R     +U           +B          -B|-R     +R          -U      -R
//                                                    ^ADDED

It's the exact same thing as before – RED still injects itself once BLUE closes (because BLUE also terminates RED) – but there are no surprises here because UNDERLINE is stateful. It underlines until it no longer does.

The "surprise" is that nesting kleur.reset inside of payloads will not (and cannot) act like other wrappers. Everything except RESET is given an open->close window. If we had to go back to bad HTML metaphors, RESET would be like a <br/>.. but only if using <br/> inside other <b>, <strong>, <em>, ..etc tags caused them to self-close and reset cleanly. HTML has a lot of self-closing rules, but that isn't one of them :D

Changing gears a bit – If the API was a kleur.RESET (constant) instead, I think we'd still have surprises:

k.red(
  k.RESET + ' XXX ' + k.blue(' YYY ') + ' ZZZ '
);

//=> "\x1B[31m\x1B[0m XXX \x1B[34m YYY \x1B[39m\x1B[31m ZZZ \x1B[39m"
//          +R    RESET        +B          -B|-R    +R          -R
//                 -R                               ^ADDED      

While the k.RESET would have reset anything that came before it, the parent k.red() still is told to wrap the payload. Given that, it sees the [39m and reinserts itself to make ZZZ red.