Skip to content

Commit 47e7268

Browse files
authored
Implement CLI TTY/terminal width/coloring (#1538)
1 parent 96e5669 commit 47e7268

13 files changed

+458
-278
lines changed

cmd/tsgo/sys.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/microsoft/typescript-go/internal/tspath"
1212
"github.com/microsoft/typescript-go/internal/vfs"
1313
"github.com/microsoft/typescript-go/internal/vfs/osvfs"
14+
"golang.org/x/term"
1415
)
1516

1617
type osSys struct {
@@ -50,6 +51,19 @@ func (s *osSys) EndWrite() {
5051
// todo: revisit if improving tsc/build/watch unittest baselines
5152
}
5253

54+
func (s *osSys) WriteOutputIsTTY() bool {
55+
return term.IsTerminal(int(os.Stdout.Fd()))
56+
}
57+
58+
func (s *osSys) GetWidthOfTerminal() int {
59+
width, _, _ := term.GetSize(int(os.Stdout.Fd()))
60+
return width
61+
}
62+
63+
func (s *osSys) GetEnvironmentVariable(name string) string {
64+
return os.Getenv(name)
65+
}
66+
5367
func newSystem() *osSys {
5468
cwd, err := os.Getwd()
5569
if err != nil {

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ require (
99
github.com/peter-evans/patience v0.3.0
1010
github.com/zeebo/xxh3 v1.0.2
1111
golang.org/x/sync v0.16.0
12-
golang.org/x/sys v0.34.0
12+
golang.org/x/sys v0.35.0
13+
golang.org/x/term v0.34.0
1314
golang.org/x/text v0.27.0
1415
gotest.tools/v3 v3.5.2
1516
)

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
2828
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
2929
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
3030
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
31-
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
32-
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
31+
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
32+
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
33+
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
34+
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
3335
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
3436
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
3537
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=

internal/core/tristate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ func (t Tristate) IsFalseOrUnknown() bool {
2929
return t == TSFalse || t == TSUnknown
3030
}
3131

32+
func (t Tristate) IsUnknown() bool {
33+
return t == TSUnknown
34+
}
35+
3236
func (t Tristate) DefaultIfUnknown(value Tristate) Tristate {
3337
if t == TSUnknown {
3438
return value

internal/execute/outputs.go

Lines changed: 147 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,82 @@ func createDiagnosticReporter(sys System, options *core.CompilerOptions) diagnos
4949
}
5050
}
5151

52+
func defaultIsPretty(sys System) bool {
53+
return sys.WriteOutputIsTTY() && sys.GetEnvironmentVariable("NO_COLOR") == ""
54+
}
55+
5256
func shouldBePretty(sys System, options *core.CompilerOptions) bool {
53-
if options == nil || options.Pretty.IsTrueOrUnknown() {
54-
// todo: return defaultIsPretty(sys);
55-
return true
57+
if options == nil || options.Pretty.IsUnknown() {
58+
return defaultIsPretty(sys)
59+
}
60+
return options.Pretty.IsTrue()
61+
}
62+
63+
type colors struct {
64+
showColors bool
65+
66+
isWindows bool
67+
isWindowsTerminal bool
68+
isVSCode bool
69+
supportsRicherColors bool
70+
}
71+
72+
func createColors(sys System) *colors {
73+
if !defaultIsPretty(sys) {
74+
return &colors{showColors: false}
75+
}
76+
77+
os := sys.GetEnvironmentVariable("OS")
78+
isWindows := strings.Contains(strings.ToLower(os), "windows")
79+
isWindowsTerminal := sys.GetEnvironmentVariable("WT_SESSION") != ""
80+
isVSCode := sys.GetEnvironmentVariable("TERM_PROGRAM") == "vscode"
81+
supportsRicherColors := sys.GetEnvironmentVariable("COLORTERM") == "truecolor" || sys.GetEnvironmentVariable("TERM") == "xterm-256color"
82+
83+
return &colors{
84+
showColors: true,
85+
isWindows: isWindows,
86+
isWindowsTerminal: isWindowsTerminal,
87+
isVSCode: isVSCode,
88+
supportsRicherColors: supportsRicherColors,
89+
}
90+
}
91+
92+
func (c *colors) bold(str string) string {
93+
if !c.showColors {
94+
return str
95+
}
96+
return "\x1b[1m" + str + "\x1b[22m"
97+
}
98+
99+
func (c *colors) blue(str string) string {
100+
if !c.showColors {
101+
return str
102+
}
103+
104+
// Effectively Powershell and Command prompt users use cyan instead
105+
// of blue because the default theme doesn't show blue with enough contrast.
106+
if c.isWindows && !c.isWindowsTerminal && !c.isVSCode {
107+
return c.brightWhite(str)
108+
}
109+
return "\x1b[94m" + str + "\x1b[39m"
110+
}
111+
112+
func (c *colors) blueBackground(str string) string {
113+
if !c.showColors {
114+
return str
115+
}
116+
if c.supportsRicherColors {
117+
return "\x1B[48;5;68m" + str + "\x1B[39;49m"
118+
} else {
119+
return "\x1b[44m" + str + "\x1B[39;49m"
56120
}
57-
return false
121+
}
122+
123+
func (c *colors) brightWhite(str string) string {
124+
if !c.showColors {
125+
return str
126+
}
127+
return "\x1b[97m" + str + "\x1b[39m"
58128
}
59129

60130
func createReportErrorSummary(sys System, options *core.CompilerOptions) func(diagnostics []*ast.Diagnostic) {
@@ -134,43 +204,42 @@ func getOptionsForHelp(commandLine *tsoptions.ParsedCommandLine) []*tsoptions.Co
134204
}
135205

136206
func getHeader(sys System, message string) []string {
137-
// !!! const colors = createColors(sys);
138-
var header []string
139-
// !!! terminalWidth := sys.GetWidthOfTerminal?.() ?? 0
140-
const tsIconLength = 5
141-
142-
// const tsIconFirstLine = colors.blueBackground("".padStart(tsIconLength));
143-
// const tsIconSecondLine = colors.blueBackground(colors.brightWhite("TS ".padStart(tsIconLength)));
144-
// // If we have enough space, print TS icon.
145-
// if (terminalWidth >= message.length + tsIconLength) {
146-
// // right align of the icon is 120 at most.
147-
// const rightAlign = terminalWidth > 120 ? 120 : terminalWidth;
148-
// const leftAlign = rightAlign - tsIconLength;
149-
// header.push(message.padEnd(leftAlign) + tsIconFirstLine + sys.newLine);
150-
// header.push("".padStart(leftAlign) + tsIconSecondLine + sys.newLine);
151-
// }
152-
// else {
153-
header = append(header, message+"\n", "\n")
154-
// }
207+
colors := createColors(sys)
208+
header := make([]string, 0, 3)
209+
terminalWidth := sys.GetWidthOfTerminal()
210+
const tsIcon = " "
211+
const tsIconTS = " TS "
212+
const tsIconLength = len(tsIcon)
213+
214+
tsIconFirstLine := colors.blueBackground(tsIcon)
215+
tsIconSecondLine := colors.blueBackground(colors.brightWhite(tsIconTS))
216+
// If we have enough space, print TS icon.
217+
if terminalWidth >= len(message)+tsIconLength {
218+
// right align of the icon is 120 at most.
219+
rightAlign := core.IfElse(terminalWidth > 120, 120, terminalWidth)
220+
leftAlign := rightAlign - tsIconLength
221+
header = append(header, fmt.Sprintf("%-*s", leftAlign, message), tsIconFirstLine, "\n")
222+
header = append(header, strings.Repeat(" ", leftAlign), tsIconSecondLine, "\n")
223+
} else {
224+
header = append(header, message, "\n", "\n")
225+
}
155226
return header
156227
}
157228

158229
func printEasyHelp(sys System, simpleOptions []*tsoptions.CommandLineOption) {
159-
// !!! const colors = createColors(sys);
230+
colors := createColors(sys)
160231
var output []string
161232
example := func(examples []string, desc *diagnostics.Message) {
162233
for _, example := range examples {
163-
// !!! colors
164-
// output.push(" " + colors.blue(example) + sys.newLine);
165-
output = append(output, " ", example, "\n")
234+
output = append(output, " ", colors.blue(example), "\n")
166235
}
167236
output = append(output, " ", desc.Format(), "\n", "\n")
168237
}
169238

170239
msg := diagnostics.X_tsc_Colon_The_TypeScript_Compiler.Format() + " - " + diagnostics.Version_0.Format(core.Version())
171240
output = append(output, getHeader(sys, msg)...)
172241

173-
output = append(output /*colors.bold(*/, diagnostics.COMMON_COMMANDS.Format() /*)*/, "\n", "\n")
242+
output = append(output, colors.bold(diagnostics.COMMON_COMMANDS.Format()), "\n", "\n")
174243

175244
example([]string{"tsc"}, diagnostics.Compiles_the_current_project_tsconfig_json_in_the_working_directory)
176245
example([]string{"tsc app.ts util.ts"}, diagnostics.Ignoring_tsconfig_json_compiles_the_specified_files_with_default_compiler_options)
@@ -192,10 +261,9 @@ func printEasyHelp(sys System, simpleOptions []*tsoptions.CommandLineOption) {
192261

193262
output = append(output, generateSectionOptionsOutput(sys, diagnostics.COMMAND_LINE_FLAGS.Format(), cliCommands /*subCategory*/, false /*beforeOptionsDescription*/, nil /*afterOptionsDescription*/, nil)...)
194263

264+
// !!! locale formatMessage
195265
after := diagnostics.You_can_learn_about_all_of_the_compiler_options_at_0.Format("https://aka.ms/tsc")
196-
output = append(output, generateSectionOptionsOutput(sys, diagnostics.COMMON_COMPILER_OPTIONS.Format(), configOpts /*subCategory*/, false /*beforeOptionsDescription*/, nil,
197-
// !!! locale formatMessage(Diagnostics.You_can_learn_about_all_of_the_compiler_options_at_0, "https://aka.ms/tsc")),
198-
&after)...)
266+
output = append(output, generateSectionOptionsOutput(sys, diagnostics.COMMON_COMPILER_OPTIONS.Format(), configOpts /*subCategory*/, false /*beforeOptionsDescription*/, nil, &after)...)
199267

200268
for _, chunk := range output {
201269
fmt.Fprint(sys.Writer(), chunk)
@@ -211,8 +279,7 @@ func generateSectionOptionsOutput(
211279
beforeOptionsDescription,
212280
afterOptionsDescription *string,
213281
) (output []string) {
214-
// !!! color
215-
output = append(output /*createColors(sys).bold(*/, sectionName /*)*/, "\n", "\n")
282+
output = append(output, createColors(sys).bold(sectionName), "\n", "\n")
216283

217284
if beforeOptionsDescription != nil {
218285
output = append(output, *beforeOptionsDescription, "\n", "\n")
@@ -274,10 +341,10 @@ func generateGroupOptionOutput(sys System, optionsList []*tsoptions.CommandLineO
274341
func generateOptionOutput(
275342
sys System,
276343
option *tsoptions.CommandLineOption,
277-
rightAlignOfLeftPart, leftAlignOfRightPart int,
344+
rightAlignOfLeft, leftAlignOfRight int,
278345
) []string {
279346
var text []string
280-
// !!! const colors = createColors(sys);
347+
colors := createColors(sys)
281348

282349
// name and description
283350
name := getDisplayNameTextOfOption(option)
@@ -298,27 +365,28 @@ func generateOptionOutput(
298365
)
299366
}
300367

301-
var terminalWidth int
302-
// !!! const terminalWidth = sys.getWidthOfTerminal?.() ?? 0;
368+
terminalWidth := sys.GetWidthOfTerminal()
303369

304-
// Note: child_process might return `terminalWidth` as undefined.
305370
if terminalWidth >= 80 {
306-
// !!! let description = "";
307-
// !!! if (option.description) {
308-
// !!! description = getDiagnosticText(option.description);
309-
// !!! }
310-
// !!! text.push(...getPrettyOutput(name, description, rightAlignOfLeft, leftAlignOfRight, terminalWidth, /*colorLeft*/ true), sys.newLine);
311-
// !!! if (showAdditionalInfoOutput(valueCandidates, option)) {
312-
// !!! if (valueCandidates) {
313-
// !!! text.push(...getPrettyOutput(valueCandidates.valueType, valueCandidates.possibleValues, rightAlignOfLeft, leftAlignOfRight, terminalWidth, /*colorLeft*/ false), sys.newLine);
314-
// !!! }
315-
// !!! if (defaultValueDescription) {
316-
// !!! text.push(...getPrettyOutput(getDiagnosticText(Diagnostics.default_Colon), defaultValueDescription, rightAlignOfLeft, leftAlignOfRight, terminalWidth, /*colorLeft*/ false), sys.newLine);
317-
// !!! }
318-
// !!! }
319-
// !!! text.push(sys.newLine);
371+
description := ""
372+
if option.Description != nil {
373+
description = option.Description.Format()
374+
}
375+
text = append(text, getPrettyOutput(colors, name, description, rightAlignOfLeft, leftAlignOfRight, terminalWidth, true /*colorLeft*/)...)
376+
text = append(text, "\n")
377+
if showAdditionalInfoOutput(valueCandidates, option) {
378+
if valueCandidates != nil {
379+
text = append(text, getPrettyOutput(colors, valueCandidates.valueType, valueCandidates.possibleValues, rightAlignOfLeft, leftAlignOfRight, terminalWidth, false /*colorLeft*/)...)
380+
text = append(text, "\n")
381+
}
382+
if defaultValueDescription != "" {
383+
text = append(text, getPrettyOutput(colors, diagnostics.X_default_Colon.Format(), defaultValueDescription, rightAlignOfLeft, leftAlignOfRight, terminalWidth, false /*colorLeft*/)...)
384+
text = append(text, "\n")
385+
}
386+
}
387+
text = append(text, "\n")
320388
} else {
321-
text = append(text /* !!! colors.blue(name) */, name, "\n")
389+
text = append(text, colors.blue(name), "\n")
322390
if option.Description != nil {
323391
text = append(text, option.Description.Format())
324392
}
@@ -444,6 +512,33 @@ func getPossibleValues(option *tsoptions.CommandLineOption) string {
444512
}
445513
}
446514

515+
func getPrettyOutput(colors *colors, left string, right string, rightAlignOfLeft int, leftAlignOfRight int, terminalWidth int, colorLeft bool) []string {
516+
// !!! How does terminalWidth interact with UTF-8 encoding? Strada just assumed UTF-16.
517+
res := make([]string, 0, 4)
518+
isFirstLine := true
519+
remainRight := right
520+
rightCharacterNumber := terminalWidth - leftAlignOfRight
521+
for len(remainRight) > 0 {
522+
curLeft := ""
523+
if isFirstLine {
524+
curLeft = fmt.Sprintf("%*s", rightAlignOfLeft, left)
525+
curLeft = fmt.Sprintf("%-*s", leftAlignOfRight, curLeft)
526+
if colorLeft {
527+
curLeft = colors.blue(curLeft)
528+
}
529+
} else {
530+
curLeft = strings.Repeat(" ", leftAlignOfRight)
531+
}
532+
533+
idx := min(rightCharacterNumber, len(remainRight))
534+
curRight := remainRight[:idx]
535+
remainRight = remainRight[idx:]
536+
res = append(res, curLeft, curRight, "\n")
537+
isFirstLine = false
538+
}
539+
return res
540+
}
541+
447542
func getDisplayNameTextOfOption(option *tsoptions.CommandLineOption) string {
448543
return "--" + option.Name + core.IfElse(option.ShortName != "", ", -"+option.ShortName, "")
449544
}

internal/execute/system.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ type System interface {
1313
FS() vfs.FS
1414
DefaultLibraryPath() string
1515
GetCurrentDirectory() string
16+
WriteOutputIsTTY() bool
17+
GetWidthOfTerminal() int
18+
GetEnvironmentVariable(name string) string
1619

1720
Now() time.Time
1821
SinceStart() time.Duration

internal/execute/testsys_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io/fs"
88
"maps"
99
"slices"
10+
"strconv"
1011
"strings"
1112
"time"
1213

@@ -50,7 +51,7 @@ interface Symbol {
5051
declare const console: { log(msg: any): void; };
5152
`)
5253

53-
func newTestSys(fileOrFolderList FileMap, cwd string) *testSys {
54+
func newTestSys(fileOrFolderList FileMap, cwd string, env map[string]string) *testSys {
5455
if cwd == "" {
5556
cwd = "/home/src/workspaces/project"
5657
}
@@ -66,6 +67,7 @@ func newTestSys(fileOrFolderList FileMap, cwd string) *testSys {
6667
output: []string{},
6768
currentWrite: &strings.Builder{},
6869
start: time.Now(),
70+
env: env,
6971
}
7072

7173
// Ensure the default library file is present
@@ -99,6 +101,7 @@ type testSys struct {
99101
defaultLibraryPath string
100102
cwd string
101103
files []string
104+
env map[string]string
102105

103106
start time.Time
104107
}
@@ -152,6 +155,21 @@ func (s *testSys) Writer() io.Writer {
152155
return s.currentWrite
153156
}
154157

158+
func (s *testSys) WriteOutputIsTTY() bool {
159+
return true
160+
}
161+
162+
func (s *testSys) GetWidthOfTerminal() int {
163+
if widthStr := s.GetEnvironmentVariable("TS_TEST_TERMINAL_WIDTH"); widthStr != "" {
164+
return core.Must(strconv.Atoi(widthStr))
165+
}
166+
return 0
167+
}
168+
169+
func (s *testSys) GetEnvironmentVariable(name string) string {
170+
return s.env[name]
171+
}
172+
155173
func sanitizeSysOutput(output string, prefixLine string, replaceString string) string {
156174
if index := strings.Index(output, prefixLine); index != -1 {
157175
indexOfNewLine := strings.Index(output[index:], "\n")

0 commit comments

Comments
 (0)