From 5fe69ac4ab8bb1da84fc80776548fcc16f89b1db Mon Sep 17 00:00:00 2001
From: Vaibhav <vrongmeal@gmail.com>
Date: Sat, 29 Feb 2020 22:42:16 +0530
Subject: [PATCH] cmd: Add `caddy fmt` command. (#3090)

This takes the config file as input and formats it.
Prints the result to stdout. Can write changes to
file if `--write` flag is passed.

Fixes #3020

Signed-off-by: Vaibhav <vrongmeal@gmail.com>
---
 caddyconfig/caddyfile/formatter.go      | 137 ++++++++++++++++++++
 caddyconfig/caddyfile/formatter_test.go | 161 ++++++++++++++++++++++++
 cmd/commandfuncs.go                     |  30 +++++
 cmd/commands.go                         |  18 +++
 4 files changed, 346 insertions(+)
 create mode 100644 caddyconfig/caddyfile/formatter.go
 create mode 100644 caddyconfig/caddyfile/formatter_test.go

diff --git a/caddyconfig/caddyfile/formatter.go b/caddyconfig/caddyfile/formatter.go
new file mode 100644
index 000000000..6cfb1b269
--- /dev/null
+++ b/caddyconfig/caddyfile/formatter.go
@@ -0,0 +1,137 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyfile
+
+import (
+	"bytes"
+	"io"
+	"unicode"
+)
+
+// Format formats a Caddyfile to conventional standards.
+func Format(body []byte) []byte {
+	reader := bytes.NewReader(body)
+	result := new(bytes.Buffer)
+
+	var (
+		commented,
+		quoted,
+		escaped,
+		block,
+		environ,
+		lineBegin bool
+
+		firstIteration = true
+
+		prev,
+		curr,
+		next rune
+
+		err error
+	)
+
+	for {
+		prev = curr
+		curr = next
+
+		if curr < 0 {
+			break
+		}
+
+		next, _, err = reader.ReadRune()
+		if err != nil {
+			if err == io.EOF {
+				next = -1
+			} else {
+				panic(err)
+			}
+		}
+
+		if firstIteration {
+			firstIteration = false
+			lineBegin = true
+			continue
+		}
+
+		if quoted {
+			if escaped {
+				escaped = false
+			} else {
+				if curr == '\\' {
+					escaped = true
+				}
+				if curr == '"' {
+					quoted = false
+				}
+			}
+			if curr == '\n' {
+				quoted = false
+			}
+		} else if commented {
+			if curr == '\n' {
+				commented = false
+			}
+		} else {
+			if curr == '"' {
+				quoted = true
+			}
+			if curr == '#' {
+				commented = true
+			}
+			if curr == '}' {
+				if environ {
+					environ = false
+				} else if block {
+					block = false
+				}
+			}
+			if curr == '{' {
+				if unicode.IsSpace(next) {
+					block = true
+
+					if !unicode.IsSpace(prev) {
+						result.WriteRune(' ')
+					}
+				} else {
+					environ = true
+				}
+			}
+			if lineBegin {
+				if curr == ' ' || curr == '\t' {
+					continue
+				} else {
+					lineBegin = false
+					if block {
+						result.WriteRune('\t')
+					}
+				}
+			} else {
+				if prev == '{' &&
+					(curr == ' ' || curr == '\t') &&
+					(next != '\n' && next != '\r') {
+					curr = '\n'
+				}
+			}
+		}
+
+		if curr == '\n' {
+			lineBegin = true
+		}
+
+		result.WriteRune(curr)
+	}
+
+	return result.Bytes()
+}
diff --git a/caddyconfig/caddyfile/formatter_test.go b/caddyconfig/caddyfile/formatter_test.go
new file mode 100644
index 000000000..a78ec7c3d
--- /dev/null
+++ b/caddyconfig/caddyfile/formatter_test.go
@@ -0,0 +1,161 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyfile
+
+import (
+	"testing"
+)
+
+func TestFormatBasicIndentation(t *testing.T) {
+	input := []byte(`
+  a
+b
+
+	c {
+		d
+}
+
+e { f
+}
+`)
+	expected := []byte(`
+a
+b
+
+c {
+	d
+}
+
+e {
+	f
+}
+`)
+	testFormat(t, input, expected)
+}
+
+func TestFormatBasicSpacing(t *testing.T) {
+	input := []byte(`
+a{
+	b
+}
+
+c{ d
+}
+`)
+	expected := []byte(`
+a {
+	b
+}
+
+c {
+	d
+}
+`)
+	testFormat(t, input, expected)
+}
+
+func TestFormatEnvironmentVariable(t *testing.T) {
+	input := []byte(`
+{$A}
+
+b {
+{$C}
+}
+
+d { {$E}
+}
+`)
+	expected := []byte(`
+{$A}
+
+b {
+	{$C}
+}
+
+d {
+	{$E}
+}
+`)
+	testFormat(t, input, expected)
+}
+
+func TestFormatComments(t *testing.T) {
+	input := []byte(`
+# a "\n"
+
+# b {
+	c
+}
+
+d {
+e # f
+# g
+}
+
+h { # i
+}
+`)
+	expected := []byte(`
+# a "\n"
+
+# b {
+c
+}
+
+d {
+	e # f
+	# g
+}
+
+h {
+	# i
+}
+`)
+	testFormat(t, input, expected)
+}
+
+func TestFormatQuotesAndEscapes(t *testing.T) {
+	input := []byte(`
+"a \"b\" #c
+	d
+
+e {
+"f"
+}
+
+g { "h"
+}
+`)
+	expected := []byte(`
+"a \"b\" #c
+d
+
+e {
+	"f"
+}
+
+g {
+	"h"
+}
+`)
+	testFormat(t, input, expected)
+}
+
+func testFormat(t *testing.T, input, expected []byte) {
+	output := Format(input)
+	if string(output) != string(expected) {
+		t.Errorf("Expected:\n%s\ngot:\n%s", string(output), string(expected))
+	}
+}
diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go
index a2c8e3de6..4f86aa834 100644
--- a/cmd/commandfuncs.go
+++ b/cmd/commandfuncs.go
@@ -34,6 +34,7 @@ import (
 
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/caddyconfig"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
 	"github.com/mholt/certmagic"
 	"go.uber.org/zap"
 )
@@ -538,6 +539,35 @@ func cmdValidateConfig(fl Flags) (int, error) {
 	return caddy.ExitCodeSuccess, nil
 }
 
+func cmdFormatConfig(fl Flags) (int, error) {
+	// Default path of file is Caddyfile
+	formatCmdConfigFile := fl.Arg(0)
+	if formatCmdConfigFile == "" {
+		formatCmdConfigFile = "Caddyfile"
+	}
+
+	formatCmdWriteFlag := fl.Bool("write")
+
+	input, err := ioutil.ReadFile(formatCmdConfigFile)
+	if err != nil {
+		return caddy.ExitCodeFailedStartup,
+			fmt.Errorf("reading input file: %v", err)
+	}
+
+	output := caddyfile.Format(input)
+
+	if formatCmdWriteFlag {
+		err = ioutil.WriteFile(formatCmdConfigFile, output, 0644)
+		if err != nil {
+			return caddy.ExitCodeFailedStartup, nil
+		}
+	} else {
+		fmt.Print(string(output))
+	}
+
+	return caddy.ExitCodeSuccess, nil
+}
+
 func cmdHelp(fl Flags) (int, error) {
 	const fullDocs = `Full documentation is available at:
 https://caddyserver.com/docs/command-line`
diff --git a/cmd/commands.go b/cmd/commands.go
index 87ded60c5..37ede3aad 100644
--- a/cmd/commands.go
+++ b/cmd/commands.go
@@ -242,6 +242,24 @@ provisioning stages.`,
 		}(),
 	})
 
+	RegisterCommand(Command{
+		Name:  "fmt",
+		Func:  cmdFormatConfig,
+		Usage: "[--write] [<path>]",
+		Short: "Formats a Caddyfile",
+		Long: `
+Formats the Caddyfile by adding proper indentation and spaces to improve
+human readability. It prints the result to stdout.
+
+If --write is specified, the output will be written to the config file
+directly instead of printing it.`,
+		Flags: func() *flag.FlagSet {
+			fs := flag.NewFlagSet("format", flag.ExitOnError)
+			fs.Bool("write", false, "Over-write the output to specified file")
+			return fs
+		}(),
+	})
+
 }
 
 // RegisterCommand registers the command cmd.