Commits

Greg Ward committed ca90837

dsl: make syntax for inline plugins nicer; trim common leading space

Require newline right after the "{{{" and before the "}}}", and strip
both of those newlines. That means the following are legal:

plugin x {{{
}}}
plugin x {{{
content
}}}
plugin x {{{
indented content
}}}

but these are not:

plugin x {{{}}}
plugin x {{{content}}}
plugin x {{{
content}}}

Comments (0)

Files changed (6)

src/fubsy/dsl/ast.go

 func (self *ASTInline) Dump(writer io.Writer, indent string) {
 	fmt.Fprintf(writer, "%sASTInline[%s] {{{", indent, self.lang)
 	if len(self.content) > 0 {
-		replace := -1 // indent all lines by default
-		if strings.HasSuffix(self.content, "\n") {
-			// last line doesn't really exist, so don't indent it
-			replace = strings.Count(self.content, "\n") - 1
+		lines := strings.Split(self.content, "\n")
+		for _, line := range lines {
+			io.WriteString(writer, "\n"+indent+"  ")
+			io.WriteString(writer, line)
 		}
-		content := strings.Replace(
-			self.content, "\n", "\n"+indent+"  ", replace)
-		fmt.Fprintf(writer, content)
 	}
-	fmt.Fprintf(writer, "%s}}}\n", indent)
+	fmt.Fprintf(writer, "\n%s}}}\n", indent)
 }
 
 func (self *ASTInline) Equal(other_ ASTNode) bool {

src/fubsy/dsl/ast_test.go

 }
 
 func Test_ASTInline_Dump(t *testing.T) {
+	tests := []struct {
+		input  string
+		indent string
+		expect string
+	}{
+		{"", "", "ASTInline[foo] {{{\n}}}\n"},
+		{"", " ", " ASTInline[foo] {{{\n }}}\n"},
+		{"foobar", "  ", "  ASTInline[foo] {{{\n    foobar\n  }}}\n"},
+		{"foobar\n", "", "ASTInline[foo] {{{\n  foobar\n  \n}}}\n"},
+		{"hello\nworld", "", "ASTInline[foo] {{{\n  hello\n  world\n}}}\n"},
+		{"\nhello\nworld", ".", ".ASTInline[foo] {{{\n.  \n.  hello\n.  world\n.}}}\n"},
+		{"\nhello\nworld\n", "", "ASTInline[foo] {{{\n  \n  hello\n  world\n  \n}}}\n"},
+		{"\nhello\nworld\n", "!", "!ASTInline[foo] {{{\n!  \n!  hello\n!  world\n!  \n!}}}\n"},
+		{"hello\n  world", "%%", "%%ASTInline[foo] {{{\n%%  hello\n%%    world\n%%}}}\n"},
+		{"hello\n  world\n", "", "ASTInline[foo] {{{\n  hello\n    world\n  \n}}}\n"},
+	}
+
 	node := &ASTInline{lang: "foo"}
-	assertASTDump(t, "ASTInline[foo] {{{}}}\n", node)
-
-	node.content = "foobar"
-	assertASTDump(t, "ASTInline[foo] {{{foobar}}}\n", node)
-
-	node.content = "foobar\n"
-	assertASTDump(t, "ASTInline[foo] {{{foobar\n}}}\n", node)
-
-	node.content = "hello\nworld"
-	assertASTDump(t, "ASTInline[foo] {{{hello\n  world}}}\n", node)
-
-	node.content = "\nhello\nworld"
-	assertASTDump(t, "ASTInline[foo] {{{\n  hello\n  world}}}\n", node)
-
-	node.content = "\nhello\nworld\n"
-	assertASTDump(t, "ASTInline[foo] {{{\n  hello\n  world\n}}}\n", node)
-
-	node.content = "hello\n  world"
-	assertASTDump(t, "ASTInline[foo] {{{hello\n    world}}}\n", node)
-
-	node.content = "hello\n  world\n"
-	assertASTDump(t, "ASTInline[foo] {{{hello\n    world\n}}}\n", node)
-
+	for i, test := range tests {
+		var buf bytes.Buffer
+		node.content = test.input
+		node.Dump(&buf, test.indent)
+		actual := buf.String()
+		if test.expect != actual {
+			t.Errorf("ASTInline.Dump() %d: expected\n%#v\nbut got\n%#v",
+				i, test.expect, actual)
+		}
+	}
 }
 
 func Test_ASTName_Equal_location(t *testing.T) {
 	assert.True(t, fcall1.Equal(fcall2),
 		"equality fails when fcall2's location different from fcall1's")
 }
-
-func assertASTDump(t *testing.T, expect string, node ASTNode) {
-	var buf bytes.Buffer
-	node.Dump(&buf, "")
-	actual := buf.String()
-	assert.Equal(t, expect, actual, "AST dump")
-}

src/fubsy/dsl/dsl_test.go

 main {
 "boo"
 }
-plugin foo {{{o'malley & friends
+plugin foo {{{
+o'malley & friends
 }}}
 blob {
  "meep"
  }`
 	fn := testutils.Mkfile(tmpdir, "valid_2.fubsy", script)
-	ast, err := Parse(fn)
-	assert.Equal(t, 0, len(err))
+	ast, errs := Parse(fn)
+	testutils.NoErrors(t, errs)
 
 	expect := &ASTRoot{children: []ASTNode{
 		&ASTPhase{
 			name:     "main",
 			children: []ASTNode{&ASTString{value: "boo"}}},
 		&ASTInline{
-			lang: "foo", content: "o'malley & friends\n"},
+			lang: "foo", content: "o'malley & friends"},
 		&ASTPhase{
 			name:     "blob",
 			children: []ASTNode{&ASTString{value: "meep"}}},

src/fubsy/dsl/fugrammar.y

 
 import (
 	"fmt"
+	"strings"
 )
 
 const BADTOKEN = -1
 inline:
 	PLUGIN NAME L3BRACE INLINE R3BRACE
 	{
-		$$ = NewASTInline($2.text, $4.text, $1, $5)
+		parser := fulex.(*Parser)
+		content, err := cleanInlineContent(parser, $4.text)
+		if err != nil {
+			parser.SetError(err)
+		} else {
+			$$ = NewASTInline($2.text, content, $1, $5)
+		}
 	}
 
 phase:
 	return token.id
 }
 
-func (self *Parser) Error(e string) {
-	self.syntaxerror = &SyntaxError{
+func (self *Parser) Error(message string) {
+	self.syntaxerror = self.NewSyntaxError(message)
+}
+
+func (self *Parser) SetError(err *SyntaxError) {
+	 self.syntaxerror = err
+}
+
+func (self *Parser) NewSyntaxError(message string) *SyntaxError {
+	 return &SyntaxError{
 		badtoken: &self.tokens[self.next-1],
-		message: e}
+		message: message,
+	}
 }
 
 func extractText(tokens []token) []string {
 	}
 	return text
 }
+
+func cleanInlineContent(parser *Parser, content string) (string, *SyntaxError) {
+	length := len(content)
+	if length == 0 {
+		return content, parser.NewSyntaxError("inline plugin must contain at least a newline")
+	}
+	if content == "\n" {
+		return "", nil
+	}
+
+	var err *SyntaxError
+	if content[0] != '\n' {
+		err = parser.NewSyntaxError("inline plugin must start with a newline")
+		return content, err
+	} else if content[length-1] != '\n' {
+		err = parser.NewSyntaxError("inline plugin must end with a newline")
+		return content, err
+	}
+
+	content = content[1 : length-1]
+
+	// trim common leading space from each line
+	lines := strings.Split(content, "\n")
+	minspace := -1
+	for _, line := range lines {
+		// safe to treat line as bytes when we're only looking for
+		// space (ASCII 32), because in UTF-8 bytes < 128 *only*
+		// represent the corresponding code point
+		for j, byte := range line {
+			if byte != ' ' {
+				if minspace < 0 || j < minspace {
+					minspace = j
+				}
+				break
+			}
+		}
+
+		if minspace == 0 {
+			// found an unindented line: give up now
+			break
+		}
+	}
+	if minspace > 0 {
+		for i, line := range lines {
+			if len(line) == 0 {
+				continue
+			} else if len(line) < minspace {
+				panic(fmt.Sprintf("line = %#v, but minspace = %d",
+					line, minspace))
+			}
+			lines[i] = line[minspace:]
+		}
+		content = strings.Join(lines, "\n")
+	}
+	return content, err
+}

src/fubsy/dsl/fugrammar_test.go

 		{PLUGIN, "plugin"},
 		{NAME, "whatever"},
 		{L3BRACE, "{{{"},
-		{INLINE, "beep!\"\nblam'"},
+		{INLINE, "\nbeep!\"\nblam'\n"},
 		{R3BRACE, "}}}"},
 		{EOL, "\n"},
 		{EOF, ""},
 		children: []ASTNode{
 			&ASTInline{lang: "whatever", content: "beep!\"\nblam'"}}}
 	assertParses(t, expect, tokens)
+
+	// a single newline is an empty plugin
+	tokens[3] = minitok{INLINE, "\n"}
+	expect.children[0].(*ASTInline).content = ""
+	assertParses(t, expect, tokens)
+}
+
+func Test_fuParse_invalid_inline(t *testing.T) {
+	tokens := []minitok{
+		{PLUGIN, "plugin"},
+		{NAME, "whatever"},
+		{L3BRACE, "{{{"},
+		{INLINE, ""},
+		{R3BRACE, "}}}"},
+		{EOL, "\n"},
+		{EOF, ""},
+	}
+
+	var parser *Parser
+
+	tests := []struct {
+		content string
+		error   string
+	}{
+		{"", "inline plugin must contain at least a newline (near }}})"},
+		{"foobar", "inline plugin must start with a newline (near }}})"},
+		{"\nfoobar", "inline plugin must end with a newline (near }}})"},
+		{"foo\nbar", "inline plugin must start with a newline (near }}})"},
+		{"\nfoo\nbar", "inline plugin must end with a newline (near }}})"},
+		{"\n  foo\n  bar\n\n  hello", "inline plugin must end with a newline (near }}})"},
+	}
+	for i, test := range tests {
+		tokens[3].text = test.content
+		parser = NewParser(toklist(tokens))
+		fuParse(parser)
+		err := parser.syntaxerror
+		if err == nil {
+			t.Errorf("%d: expected an error, but got none", i)
+		} else if test.error != err.Error() {
+			t.Errorf("%d: expected error:\n%s\nbut got\n%s",
+				i, test.error, err.Error())
+		}
+	}
+}
+
+func Test_cleanInlineContent(t *testing.T) {
+	// cleanInlineContent() trims leading and trailing newline from
+	// the whole string, and common leading space from each line
+	tests := []struct {
+		input  string
+		expect string
+	}{
+		{"\n", ""},
+		{"\n\n", ""},
+		{"\nfoo\n", "foo"},
+		{"\nfoo\n  bar\n", "foo\n  bar"},
+		{"\n  foo\n", "foo"},
+		{"\n  foo\n  bar\n    deeper\n  back\n", "foo\nbar\n  deeper\nback"},
+		{"\n    deep\n  less deep\n    deep again\n", "  deep\nless deep\n  deep again"},
+		{"\n    deep\n     deeper\nunindented\n", "    deep\n     deeper\nunindented"},
+		// blank lines don't need to have indentation
+		{"\n  foo\n  bar\n\n  hello\n", "foo\nbar\n\nhello"},
+		{"\n\n  foo\n    bar\n  foo\n", "\nfoo\n  bar\nfoo"},
+	}
+	var parser *Parser = NewParser(nil)
+	for i, test := range tests {
+		actual, err := cleanInlineContent(parser, test.input)
+		assert.Nil(t, err)
+		if test.expect != actual {
+			t.Errorf("cleanInlineContent %d: "+
+				"input %#v; expected\n%#v\nbut got\n%#v",
+				i, test.input, test.expect, actual)
+		}
+	}
 }
 
 func Test_fuParse_invalid(t *testing.T) {

src/fubsy/testutils/testutils.go

 package testutils
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
 )
 
+// Ensure that errs is empty. If not, fail the current test
+// (non-fatally) and return false. Otherwise return true.
+func NoErrors(t *testing.T, errs []error) bool {
+	if len(errs) == 0 {
+		return true
+	}
+	formatted := make([]string, len(errs))
+	for i, err := range errs {
+		formatted[i] = fmt.Sprintf("%T: %s", err, err.Error())
+	}
+	caller := ""
+	_, file, line, ok := runtime.Caller(1)
+	if ok {
+		caller = fmt.Sprintf("%s:%d: ", filepath.Base(file), line)
+	}
+	t.Errorf("%sexpected no errors, but got %d:\n%s",
+		caller, len(errs), strings.Join(formatted, "\n"))
+	return false
+}
+
 // Create a temporary directory. Return the name of the directory and
 // a function to clean it up when you're done with it. Panics on any
 // error (as does the cleanup function).