diff --git a/frontend/src/common/prettier/plugins/docker/docker_fmt.wasm b/frontend/src/common/prettier/plugins/docker/docker_fmt.wasm index c95d9c0..f5e8f29 100644 Binary files a/frontend/src/common/prettier/plugins/docker/docker_fmt.wasm and b/frontend/src/common/prettier/plugins/docker/docker_fmt.wasm differ diff --git a/frontend/src/common/prettier/plugins/docker/format.go b/frontend/src/common/prettier/plugins/docker/format.go index 0e33610..d906d2b 100644 --- a/frontend/src/common/prettier/plugins/docker/format.go +++ b/frontend/src/common/prettier/plugins/docker/format.go @@ -7,7 +7,7 @@ import ( "strings" "syscall/js" - "github.com/reteps/dockerfmt/lib" + "docker_fmt/lib" ) func Format(this js.Value, args []js.Value) any { diff --git a/frontend/src/common/prettier/plugins/docker/go.mod b/frontend/src/common/prettier/plugins/docker/go.mod index 9deee8c..1fb3781 100644 --- a/frontend/src/common/prettier/plugins/docker/go.mod +++ b/frontend/src/common/prettier/plugins/docker/go.mod @@ -2,15 +2,16 @@ module docker_fmt go 1.25.0 -require github.com/reteps/dockerfmt v0.3.7 +require ( + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/moby/buildkit v0.24.0 + mvdan.cc/sh/v3 v3.12.0 +) require ( github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/moby/buildkit v0.20.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - google.golang.org/protobuf v1.35.2 // indirect - mvdan.cc/sh/v3 v3.11.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/frontend/src/common/prettier/plugins/docker/go.sum b/frontend/src/common/prettier/plugins/docker/go.sum index 7d27d3b..9dca282 100644 --- a/frontend/src/common/prettier/plugins/docker/go.sum +++ b/frontend/src/common/prettier/plugins/docker/go.sum @@ -18,6 +18,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/moby/buildkit v0.20.2 h1:qIeR47eQ1tzI1rwz0on3Xx2enRw/1CKjFhoONVcTlMA= github.com/moby/buildkit v0.20.2/go.mod h1:DhaF82FjwOElTftl0JUAJpH/SUIUx4UvcFncLeOtlDI= +github.com/moby/buildkit v0.24.0 h1:qYfTl7W1SIJzWDIDCcPT8FboHIZCYfi++wvySi3eyFE= +github.com/moby/buildkit v0.24.0/go.mod h1:4qovICAdR2H4C7+EGMRva5zgHW1gyhT4/flHI7F5F9k= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -59,7 +61,11 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw= mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg= +mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= +mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg= diff --git a/frontend/src/common/prettier/plugins/docker/lib/format.go b/frontend/src/common/prettier/plugins/docker/lib/format.go new file mode 100644 index 0000000..f386552 --- /dev/null +++ b/frontend/src/common/prettier/plugins/docker/lib/format.go @@ -0,0 +1,800 @@ +package lib + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "regexp" + "slices" + "strings" + + "github.com/google/shlex" + "github.com/moby/buildkit/frontend/dockerfile/command" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "mvdan.cc/sh/v3/syntax" +) + +type ExtendedNode struct { + *parser.Node + Children []*ExtendedNode + Next *ExtendedNode + OriginalMultiline string +} + +type ParseState struct { + CurrentLine int + Output string + // Needed to pull in comments + AllOriginalLines []string + Config *Config +} + +type Config struct { + IndentSize uint + TrailingNewline bool + SpaceRedirects bool +} + +func FormatNode(ast *ExtendedNode, c *Config) (string, bool) { + nodeName := strings.ToLower(ast.Node.Value) + dispatch := map[string]func(*ExtendedNode, *Config) string{ + command.Add: formatSpaceSeparated, + command.Arg: formatBasic, + command.Cmd: formatCmd, + command.Copy: formatSpaceSeparated, + command.Entrypoint: formatEntrypoint, + command.Env: formatEnv, + command.Expose: formatSpaceSeparated, + command.From: formatSpaceSeparated, + command.Healthcheck: formatBasic, + command.Label: formatLabel, + command.Maintainer: formatMaintainer, + command.Onbuild: FormatOnBuild, + command.Run: formatRun, + command.Shell: formatCmd, + command.StopSignal: formatBasic, + command.User: formatBasic, + command.Volume: formatBasic, + command.Workdir: formatSpaceSeparated, + } + + fmtFunc := dispatch[nodeName] + if fmtFunc == nil { + return "", false + // log.Fatalf("Unknown command: %s %s\n", nodeName, ast.OriginalMultiline) + } + return fmtFunc(ast, c), true +} + +func (df *ParseState) processNode(ast *ExtendedNode) { + + // We don't want to process nodes that don't have a start or end line. + if ast.Node.StartLine == 0 || ast.Node.EndLine == 0 { + return + } + + // check if we are on the correct line, + // otherwise get the comments we are missing + if df.CurrentLine != ast.StartLine { + df.Output += FormatComments(df.AllOriginalLines[df.CurrentLine : ast.StartLine-1]) + df.CurrentLine = ast.StartLine + } + // if df.Output != "" { + // // If the previous line isn't a comment or newline, add a newline + // lastTwoChars := df.Output[len(df.Output)-2 : len(df.Output)] + // lastNonTrailingNewline := strings.LastIndex(strings.TrimRight(df.Output, "\n"), "\n") + // if lastTwoChars != "\n\n" && df.Output[lastNonTrailingNewline+1] != '#' { + // df.Output += "\n" + // } + // } + + output, ok := FormatNode(ast, df.Config) + if ok { + df.Output += output + df.CurrentLine = ast.EndLine + } + // fmt.Printf("CurrentLine: %d, %d\n", df.CurrentLine, ast.EndLine) + // fmt.Printf("Unknown command: %s %s\n", nodeName, ast.OriginalMultiline) + + for _, child := range ast.Children { + df.processNode(child) + } + + // fmt.Printf("CurrentLine2: %d, %d\n", df.CurrentLine, ast.EndLine) + + if ast.Node.Next != nil { + df.processNode(ast.Next) + } +} + +func FormatOnBuild(n *ExtendedNode, c *Config) string { + if len(n.Node.Next.Children) == 1 { + // fmt.Printf("Onbuild: %s\n", n.Node.Next.Children[0].Value) + output, ok := FormatNode(n.Next.Children[0], c) + if ok { + return strings.ToUpper(n.Node.Value) + " " + output + } + } + + return n.OriginalMultiline +} + +func FormatFileLines(fileLines []string, c *Config) string { + result, err := parser.Parse(strings.NewReader(strings.Join(fileLines, ""))) + if err != nil { + log.Printf("%s\n", strings.Join(fileLines, "")) + log.Fatalf("Error parsing file: %v", err) + } + + parseState := &ParseState{ + CurrentLine: 0, + Output: "", + AllOriginalLines: fileLines, + } + rootNode := BuildExtendedNode(result.AST, fileLines) + parseState.Config = c + parseState.processNode(rootNode) + + // After all directives are processed, we need to check if we have any trailing comments to add. + if parseState.CurrentLine < len(parseState.AllOriginalLines) { + // Add the rest of the file + parseState.Output += FormatComments(parseState.AllOriginalLines[parseState.CurrentLine:]) + } + + parseState.Output = strings.TrimRight(parseState.Output, "\n") + // Ensure the output ends with a newline if requested + if c.TrailingNewline { + parseState.Output += "\n" + } + return parseState.Output +} + +func BuildExtendedNode(n *parser.Node, fileLines []string) *ExtendedNode { + // Build an extended node from the parser node + // This is used to add the original multiline string to the node + // and to add the original line numbers + + if n == nil { + return nil + } + + // Create the extended node with the current parser node + en := &ExtendedNode{ + Node: n, + Next: nil, + Children: nil, + OriginalMultiline: "", // Default to empty string + } + + // If we have valid start and end lines, construct the multiline representation + if n.StartLine > 0 && n.EndLine > 0 { + // Subtract 1 from StartLine because fileLines is 0-indexed while StartLine is 1-indexed + for i := n.StartLine - 1; i < n.EndLine; i++ { + en.OriginalMultiline += fileLines[i] + } + } + + // Process all children recursively + if len(n.Children) > 0 { + childrenNodes := make([]*ExtendedNode, 0, len(n.Children)) + for _, child := range n.Children { + extChild := BuildExtendedNode(child, fileLines) + if extChild != nil { + childrenNodes = append(childrenNodes, extChild) + } + } + // Replace the children with the processed ones + en.Children = childrenNodes + } + + // Process the next node recursively + if n.Next != nil { + extNext := BuildExtendedNode(n.Next, fileLines) + if extNext != nil { + en.Next = extNext + } + } + + return en +} + +func formatEnv(n *ExtendedNode, c *Config) string { + // Only the legacy format will have a empty 3rd child + if n.Next.Next.Next.Value == "" { + return strings.ToUpper(n.Node.Value) + " " + n.Next.Node.Value + "=" + n.Next.Next.Node.Value + "\n" + } + // Otherwise, we have a valid env command + originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t") + content := StripWhitespace(regexp.MustCompile(" ").Split(originalTrimmed, 2)[1], true) + // Indent all lines with indentSize spaces + re := regexp.MustCompile("(?m)^ *") + content = strings.Trim(re.ReplaceAllString(content, strings.Repeat(" ", int(c.IndentSize))), " ") + return strings.ToUpper(n.Value) + " " + content +} + +func formatShell(content string, hereDoc bool, c *Config) string { + // Semicolons require special handling so we don't break the command + // Improved semicolon support: handle escaped semicolons properly + + // Check for unescaped semicolons - if found, try to format them properly + if regexp.MustCompile(`[^\\];`).MatchString(content) { + // Split by unescaped semicolons and format each part separately + parts := regexp.MustCompile(`([^\\]);`).Split(content, -1) + if len(parts) > 1 { + var formattedParts []string + for i, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + // Try to format each part individually + formatted := formatSingleCommand(part, hereDoc, c) + formattedParts = append(formattedParts, formatted) + } + // Add semicolon back except for the last part + if i < len(parts)-1 { + formattedParts[len(formattedParts)-1] += ";" + } + } + return strings.Join(formattedParts, " ") + } + // If splitting didn't work, fall back to original content + return content + } + // Grouped expressions aren't formatted well + // See: https://github.com/mvdan/sh/issues/1148 + if strings.Contains(content, "{ \\") { + return content + } + + if !hereDoc { + // Here lies some cursed magic. Be careful. + + // Replace comments with a subshell evaluation -- they won't be run so we can do this. + content = StripWhitespace(content, true) + lineComment := regexp.MustCompile(`(\n\s*)(#.*)`) + lines := strings.SplitAfter(content, "\n") + for i := range lines { + lineTrim := strings.TrimLeft(lines[i], " \t") + if len(lineTrim) >= 1 && lineTrim[0] == '#' { + lines[i] = strings.ReplaceAll(lines[i], "`", "×") + } + } + content = strings.Join(lines, "") + + content = lineComment.ReplaceAllString(content, "$1`$2#`\\") + + /* + ``` + foo \ + `#comment#`\ + && bar + ``` + + ``` + foo && \ + `#comment#` \ + bar + ``` + */ + + commentContinuation := regexp.MustCompile(`(\\(?:\s*` + "`#.*#`" + `\\){1,}\s*)&&`) + content = commentContinuation.ReplaceAllString(content, "&&$1") + + // log.Printf("Content0: %s\n", content) + lines = strings.SplitAfter(content, "\n") + /** + if the next line is not a comment, and we didn't start with a continuation, don't add the `&&`. + */ + inContinuation := false + for i := range lines { + lineTrim := strings.Trim(lines[i], " \t\\\n") + // fmt.Printf("LineTrim: %s\n", lineTrim) + nextLine := "" + isComment := false + nextLineIsComment := false + if i+1 < len(lines) { + nextLine = strings.Trim(lines[i+1], " \t\\\n") + } + if len(nextLine) >= 2 && nextLine[:2] == "`#" { + nextLineIsComment = true + } + if len(lineTrim) >= 2 && lineTrim[:2] == "`#" { + isComment = true + } + + // fmt.Printf("isComment: %v, nextLineIsComment: %v, inContinuation: %v\n", isComment, nextLineIsComment, inContinuation) + if isComment && (inContinuation || nextLineIsComment) { + lines[i] = strings.Replace(lines[i], "#`\\", "#`&&\\", 1) + } + + if len(lineTrim) >= 2 && !isComment && lineTrim[len(lineTrim)-2:] == "&&" { + inContinuation = true + } else if !isComment { + inContinuation = false + } + } + + content = strings.Join(lines, "") + } + + // Now that we have a valid bash-style command, we can format it with shfmt + // log.Printf("Content1: %s\n", content) + content = formatBash(content, c) + + // log.Printf("Content2: %s\n", content) + + if !hereDoc { + reBacktickComment := regexp.MustCompile(`([ \t]*)(?:&& )?` + "`(#.*)#` " + `\\`) + content = reBacktickComment.ReplaceAllString(content, "$1$2") + + // Fixup the comment indentation + lines := strings.SplitAfter(content, "\n") + prevIsComment := false + prevCommentSpacing := "" + firstLineIsComment := false + for i := range lines { + lineTrim := strings.TrimLeft(lines[i], " \t") + // fmt.Printf("LineTrim: %s, %v\n", lineTrim, prevIsComment) + if len(lineTrim) >= 1 && lineTrim[0] == '#' { + if i == 0 { + firstLineIsComment = true + lines[i] = strings.Repeat(" ", int(c.IndentSize)) + lineTrim + } + lineParts := strings.SplitN(lines[i], "#", 2) + + if prevIsComment { + lines[i] = prevCommentSpacing + "#" + lineParts[1] + } else { + prevCommentSpacing = lineParts[0] + } + prevIsComment = true + } else { + prevIsComment = false + } + } + // TODO: this formatting isn't perfect (see tests/out/run5.dockerfile) + if firstLineIsComment { + lines = slices.Insert(lines, 0, "\\\n") + } + content = strings.Join(lines, "") + content = strings.ReplaceAll(content, "×", "`") + + } + return content +} + +// formatSingleCommand formats a single shell command (used for semicolon-separated commands) +func formatSingleCommand(content string, hereDoc bool, c *Config) string { + // Grouped expressions aren't formatted well + // See: https://github.com/mvdan/sh/issues/1148 + if strings.Contains(content, "{ \\") { + return content + } + + if !hereDoc { + // Here lies some cursed magic. Be careful. + + // Replace comments with a subshell evaluation -- they won't be run so we can do this. + content = StripWhitespace(content, true) + content = regexp.MustCompile(`#.*`).ReplaceAllString(content, "$(: comment)") + content = strings.ReplaceAll(content, "\\\n", " ") + content = strings.ReplaceAll(content, "\n", " ") + content = regexp.MustCompile(`\s+`).ReplaceAllString(content, " ") + content = strings.TrimSpace(content) + } + + return formatBash(content, c) +} + +func formatRun(n *ExtendedNode, c *Config) string { + // Get the original RUN command text + hereDoc := false + flags := n.Node.Flags + + var content string + if len(n.Node.Heredocs) >= 1 { + content = n.Node.Heredocs[0].Content + hereDoc = true + // Check if heredoc FileDescriptor is 0 (stdin) - this is the standard for RUN commands + if n.Node.Heredocs[0].FileDescriptor != 0 { + log.Printf("Warning: heredoc FileDescriptor is %d, expected 0 for RUN command", n.Node.Heredocs[0].FileDescriptor) + } + } else { + // We split the original multiline string by whitespace + originalText := n.OriginalMultiline + if n.OriginalMultiline == "" { + // If the original multiline string is empty, use the original value + originalText = n.Node.Original + } + + originalTrimmed := strings.TrimLeft(originalText, " \t") + parts := regexp.MustCompile("[ \t]").Split(originalTrimmed, 2+len(flags)) + content = parts[1+len(flags)] + } + // Try to parse as JSON + jsonItems, err := parseJSONStringArray(content) + if err == nil { + outStr := marshalStringArray(jsonItems) + outStr = strings.ReplaceAll(outStr, "\",\"", "\", \"") + content = outStr + "\n" + } else { + content = formatShell(content, hereDoc, c) + if hereDoc { + n.Node.Heredocs[0].Content = content + content, _ = GetHeredoc(n) + } + } + + if len(flags) > 0 { + content = strings.Join(flags, " ") + " " + content + } + + return strings.ToUpper(n.Value) + " " + content +} + +func GetHeredoc(n *ExtendedNode) (string, bool) { + if len(n.Node.Heredocs) == 0 { + return "", false + } + + // printAST(n, 0) + args := []string{} + cur := n.Next + for cur != nil { + if cur.Node.Value != "" { + args = append(args, cur.Node.Value) + } + cur = cur.Next + } + content := strings.Join(args, " ") + "\n" + n.Node.Heredocs[0].Content + n.Node.Heredocs[0].Name + "\n" + return content, true +} +func formatBasic(n *ExtendedNode, c *Config) string { + // Uppercases the command, and indent the following lines + originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t") + + value, success := GetHeredoc(n) + if !success { + value = regexp.MustCompile(" ").Split(originalTrimmed, 2)[1] + } + return IndentFollowingLines(strings.ToUpper(n.Value)+" "+value, c.IndentSize) +} + +// marshalStringArray manually creates a JSON array string from a slice of strings +// This avoids using encoding/json which causes reflection issues in WASM +func marshalStringArray(items []string) string { + if len(items) == 0 { + return "[]" + } + + var result strings.Builder + result.WriteString("[") + + for i, item := range items { + if i > 0 { + result.WriteString(", ") + } + result.WriteString("\"") + // Escape quotes and backslashes in the string + escaped := strings.ReplaceAll(item, "\\", "\\\\") + escaped = strings.ReplaceAll(escaped, "\"", "\\\"") + result.WriteString(escaped) + result.WriteString("\"") + } + + result.WriteString("]") + return result.String() +} + +// parseJSONStringArray manually parses a JSON array string into a slice of strings +// This avoids using encoding/json which causes reflection issues in WASM +func parseJSONStringArray(jsonStr string) ([]string, error) { + jsonStr = strings.TrimSpace(jsonStr) + if !strings.HasPrefix(jsonStr, "[") || !strings.HasSuffix(jsonStr, "]") { + return nil, fmt.Errorf("not a JSON array") + } + + // Remove brackets + content := strings.TrimSpace(jsonStr[1 : len(jsonStr)-1]) + if content == "" { + return []string{}, nil + } + + var result []string + var current strings.Builder + inQuotes := false + escaped := false + + for i, char := range content { + if escaped { + switch char { + case '"': + current.WriteRune('"') + case '\\': + current.WriteRune('\\') + case 'n': + current.WriteRune('\n') + case 't': + current.WriteRune('\t') + case 'r': + current.WriteRune('\r') + default: + current.WriteRune('\\') + current.WriteRune(char) + } + escaped = false + continue + } + + if char == '\\' { + escaped = true + continue + } + + if char == '"' { + inQuotes = !inQuotes + continue + } + + if !inQuotes && char == ',' { + result = append(result, current.String()) + current.Reset() + // Skip whitespace after comma + for i+1 < len(content) && (content[i+1] == ' ' || content[i+1] == '\t') { + i++ + } + continue + } + + if inQuotes { + current.WriteRune(char) + } + } + + // Add the last item + if current.Len() > 0 || len(result) > 0 { + result = append(result, current.String()) + } + + return result, nil +} + +func getCmd(n *ExtendedNode, shouldSplitNode bool) []string { + cmd := []string{} + for node := n; node != nil; node = node.Next { + // Split value by whitespace + rawValue := strings.Trim(node.Node.Value, " \t") + if len(node.Node.Flags) > 0 { + cmd = append(cmd, node.Node.Flags...) + } + // log.Printf("ShouldSplitNode: %v\n", shouldSplitNode) + if shouldSplitNode { + parts, err := shlex.Split(rawValue) + if err != nil { + log.Fatalf("Error splitting: %s\n", node.Node.Value) + } + cmd = append(cmd, parts...) + } else { + cmd = append(cmd, rawValue) + } + } + // log.Printf("getCmd: %v\n", cmd) + return cmd +} + +func shouldRunInShell(node string) bool { + // https://docs.docker.com/reference/dockerfile/#entrypoint + parts, err := shlex.Split(node) + if err != nil { + log.Fatalf("Error splitting: %s\n", node) + } + + needsShell := false + // This is a simplistic check to determine if we need to run in a full shell. + for _, part := range parts { + if part == "&&" || part == ";" || part == "||" { + needsShell = true + break + } + } + + return needsShell +} +func formatEntrypoint(n *ExtendedNode, c *Config) string { + // this can technically change behavior. https://docs.docker.com/reference/dockerfile/#understand-how-cmd-and-entrypoint-interact + return formatCmd(n, c) +} +func formatCmd(n *ExtendedNode, c *Config) string { + // printAST(n, 0) + isJSON, ok := n.Node.Attributes["json"] + if !ok { + isJSON = false + } + + if !isJSON { + doNotSplit := shouldRunInShell(n.Node.Next.Value) + if doNotSplit { + n.Next.Node.Flags = append(n.Next.Node.Flags, []string{"/bin/sh", "-c"}...) + // Hacky workaround to tell getCmd to not split the command + isJSON = true + } + } + + cmd := getCmd(n.Next, !isJSON) + bWithSpace := marshalStringArray(cmd) + bWithSpace = strings.ReplaceAll(bWithSpace, "\",\"", "\", \"") + return strings.ToUpper(n.Node.Value) + " " + bWithSpace + "\n" +} + +func formatSpaceSeparated(n *ExtendedNode, c *Config) string { + isJSON, ok := n.Node.Attributes["json"] + if !ok { + isJSON = false + } + cmd, success := GetHeredoc(n) + if !success { + cmd = strings.Join(getCmd(n.Next, isJSON), " ") + if len(n.Node.Flags) > 0 { + cmd = strings.Join(n.Node.Flags, " ") + " " + cmd + } + cmd += "\n" + } + + return strings.ToUpper(n.Node.Value) + " " + cmd +} + +func formatLabel(n *ExtendedNode, c *Config) string { + // Parse LABEL key-value pairs and sort them alphabetically by key + originalTrimmed := strings.TrimLeft(n.OriginalMultiline, " \t") + content := regexp.MustCompile(" ").Split(originalTrimmed, 2)[1] + + // Parse key-value pairs + labels := make(map[string]string) + var keys []string + + // Split by whitespace and parse key=value pairs + parts := strings.Fields(content) + for _, part := range parts { + if strings.Contains(part, "=") { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + key := strings.Trim(kv[0], "\"") + value := strings.Trim(kv[1], "\"") + labels[key] = value + keys = append(keys, key) + } + } + } + + // If no key-value pairs found, fall back to basic formatting + if len(keys) == 0 { + return formatBasic(n, c) + } + + // Sort keys alphabetically + slices.Sort(keys) + + // Build sorted output + var result strings.Builder + result.WriteString(strings.ToUpper(n.Value)) + result.WriteString(" ") + + for i, key := range keys { + if i > 0 { + result.WriteString(" ") + } + result.WriteString(key) + result.WriteString("=\"") + result.WriteString(labels[key]) + result.WriteString("\"") + } + + result.WriteString("\n") + return result.String() +} + +func formatMaintainer(n *ExtendedNode, c *Config) string { + + // Get text between quotes + maintainer := strings.Trim(n.Next.Node.Value, "\"") + return "LABEL org.opencontainers.image.authors=\"" + maintainer + "\"\n" +} + +func GetFileLines(fileName string) ([]string, error) { + // Open the file + f, err := os.Open(fileName) + if err != nil { + return []string{}, err + } + defer f.Close() + + // Read the file contents + b := new(strings.Builder) + io.Copy(b, f) + fileLines := strings.SplitAfter(b.String(), "\n") + + return fileLines, nil +} + +func StripWhitespace(lines string, rightOnly bool) string { + // Split the string into lines by newlines + // log.Printf("Lines: .%s.\n", lines) + linesArray := strings.SplitAfter(lines, "\n") + // Create a new slice to hold the stripped lines + var strippedLines string + // Iterate over each line + for _, line := range linesArray { + // Trim leading and trailing whitespace + // log.Printf("Line .%s.\n", line) + hadNewline := len(line) > 0 && line[len(line)-1] == '\n' + if rightOnly { + // Only trim trailing whitespace + line = strings.TrimRight(line, " \t\n") + } else { + // Trim both leading and trailing whitespace + line = strings.Trim(line, " \t\n") + } + + // log.Printf("Line2 .%s.", line) + if hadNewline { + line += "\n" + } + strippedLines += line + } + return strippedLines +} + +func FormatComments(lines []string) string { + // Adds lines to the output, collapsing multiple newlines into a single newline + // and removing leading / trailing whitespace. We can do this because + // we are adding comments and we don't care about the formatting. + missingContent := StripWhitespace(strings.Join(lines, ""), false) + // Replace multiple newlines with a single newline + re := regexp.MustCompile(`\n{3,}`) + return re.ReplaceAllString(missingContent, "\n") +} + +func IndentFollowingLines(lines string, indentSize uint) string { + // Split the input by lines + allLines := strings.SplitAfter(lines, "\n") + + // If there's only one line or no lines, return the original + if len(allLines) <= 1 { + return lines + } + + // Keep the first line as is + result := allLines[0] + // Indent all subsequent lines + for i := 1; i < len(allLines); i++ { + if allLines[i] != "" { // Skip empty lines + // Remove existing indentation and add new indentation + trimmedLine := strings.TrimLeft(allLines[i], " \t") + allLines[i] = strings.Repeat(" ", int(indentSize)) + trimmedLine + } + + // Add to result (with newline except for the last line) + result += allLines[i] + } + + return result +} + +func formatBash(s string, c *Config) string { + r := strings.NewReader(s) + f, err := syntax.NewParser(syntax.KeepComments(true)).Parse(r, "") + if err != nil { + fmt.Printf("Error parsing: %s\n", s) + panic(err) + } + buf := new(bytes.Buffer) + syntax.NewPrinter( + syntax.Minify(false), + syntax.SingleLine(false), + syntax.SpaceRedirects(c.SpaceRedirects), + syntax.Indent(c.IndentSize), + syntax.BinaryNextLine(true), + ).Print(buf, f) + return buf.String() +}