Skip to content

Commit 79ff833

Browse files
Merge pull request #63 from Kyorai/cuttlefish-60-multiline-values
Support multiline configuration values
2 parents 7401dc2 + ef7ebd8 commit 79ff833

17 files changed

+193
-2
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,40 @@ log.console.file = /var/log/console.log
9898
log.syslog = on
9999
```
100100

101+
### Multiline Values
102+
103+
Cuttlefish supports multiline values using here-document syntax with triple
104+
single quotes (`'''`). This is useful for embedding structured content like
105+
JSON, scripts, or formatted text:
106+
107+
```ini
108+
# Simple multiline value
109+
description = '''
110+
This is a multiline description
111+
that spans multiple lines
112+
and preserves formatting.
113+
'''
114+
115+
# JSON configuration example
116+
api.config = '''
117+
{
118+
"endpoints": {
119+
"health": "/health",
120+
"metrics": "/metrics"
121+
},
122+
"timeout": 30,
123+
"retries": 3
124+
}
125+
'''
126+
127+
# Inline multiline (no newlines)
128+
short.value = '''single line content'''
129+
```
130+
131+
Whitespace within the `'''` delimiters is preserved exactly as written,
132+
including newlines and indentation. Leading and trailing newlines and carriage
133+
returns are automatically trimmed to provide clean configuration values.
134+
101135
More information for users here:
102136
https://github.com/basho/cuttlefish/wiki/Cuttlefish-for-Application-Users
103137

src/conf_parse.erl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ parse(Input) when is_binary(Input) ->
221221

222222
-spec 'setting'(input(), index()) -> parse_result().
223223
'setting'(Input, Index) ->
224-
p(Input, Index, 'setting', fun(I,D) -> (p_seq([p_zero_or_more(fun 'ws'/2), fun 'key'/2, p_zero_or_more(fun 'ws'/2), p_string(<<"=">>), p_zero_or_more(fun 'ws'/2), p_choose([fun 'escaped_value'/2, fun 'unescaped_value'/2]), p_zero_or_more(fun 'ws'/2), p_optional(fun 'comment'/2)]))(I,D) end, fun(Node, Idx) ->
224+
p(Input, Index, 'setting', fun(I,D) -> (p_seq([p_zero_or_more(fun 'ws'/2), fun 'key'/2, p_zero_or_more(fun 'ws'/2), p_string(<<"=">>), p_zero_or_more(fun 'ws'/2), p_choose([fun 'multiline_value'/2, fun 'escaped_value'/2, fun 'unescaped_value'/2]), p_zero_or_more(fun 'ws'/2), p_optional(fun 'comment'/2)]))(I,D) end, fun(Node, Idx) ->
225225
[ _, Key, _, _Eq, _, Value, _, _ ] = Node,
226226
{Key, try_unicode_characters_to_list(Value, Idx)}
227227
end).
@@ -233,6 +233,13 @@ parse(Input) when is_binary(Input) ->
233233
[try_unicode_characters_to_list(H, Idx)| [try_unicode_characters_to_list(W, Idx) || [_, W] <- T]]
234234
end).
235235

236+
-spec 'multiline_value'(input(), index()) -> parse_result().
237+
'multiline_value'(Input, Index) ->
238+
p(Input, Index, 'multiline_value', fun(I,D) -> (p_seq([p_string(<<"\'\'\'">>), p_choose([p_zero_or_more(p_seq([p_not(p_string(<<"\'\'\'">>)), p_anything()])), fun 'crlf'/2]), p_string(<<"\'\'\'">>)]))(I,D) end, fun(Node, Idx) ->
239+
Node0 = string:trim(Node, both, [$',$\n,[$\r,$\n]]),
240+
try_unicode_characters_to_list(Node0, Idx)
241+
end).
242+
236243
-spec 'escaped_value'(input(), index()) -> parse_result().
237244
'escaped_value'(Input, Index) ->
238245
p(Input, Index, 'escaped_value', fun(I,D) -> (p_seq([p_string(<<"\'">>), p_zero_or_more(p_seq([p_not(p_string(<<"\'">>)), p_anything()])), p_string(<<"\'">>)]))(I,D) end, fun(Node, Idx) ->

src/conf_parse.peg

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ line <- ((setting / include / comment / ws+) (crlf / eof)) / crlf %{
5656

5757
%% A setting is a key and a value, joined by =, with surrounding
5858
%% whitespace ignored.
59-
setting <- ws* key ws* "=" ws* (escaped_value / unescaped_value) ws* comment? %{
59+
setting <- ws* key ws* "=" ws* (multiline_value / escaped_value / unescaped_value) ws* comment? %{
6060
[ _, Key, _, _Eq, _, Value, _, _ ] = Node,
6161
{Key, try_unicode_characters_to_list(Value, Idx)}
6262
%};
@@ -67,6 +67,12 @@ key <- head:word tail:("." word)* %{
6767
[try_unicode_characters_to_list(H, Idx)| [try_unicode_characters_to_list(W, Idx) || [_, W] <- T]]
6868
%};
6969

70+
%% A multiline value is any character between two pairs of triple single quotes, spanning lines
71+
multiline_value <- "'''" ((!"'''" .)* / crlf) "'''" %{
72+
Node0 = string:trim(Node, both, [$',$\n,[$\r,$\n]]),
73+
try_unicode_characters_to_list(Node0 Idx)
74+
%};
75+
7076
%% An escaped value is any character between single quotes except for EOF
7177
escaped_value <- "'" (!"'" .)* "'" %{
7278
Stripped = string:trim(Node, both, [$']),

src/cuttlefish_conf.erl

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,94 @@ generate_comments_test() ->
351351
Comments = generate_comments(SchemaElement),
352352
?assertEqual(["## Hi!", "## Bye!", "## ", "## Acceptable values:", "## - text"], Comments).
353353

354+
normalize_newlines(Conf) ->
355+
normalize_newlines(Conf, {os_type, os:type()}).
356+
357+
normalize_newlines(Conf, {os_type, {win32, _}}) ->
358+
{ok, Regex} = re:compile("\r\n", [unicode]),
359+
F = fun (V) ->
360+
re:replace(V, Regex, "\n", [global, {return, list}])
361+
end,
362+
normalize_newlines(Conf, F, []);
363+
normalize_newlines(Conf, {os_type, _}) ->
364+
normalize_newlines(Conf, fun (V) -> V end, []).
365+
366+
normalize_newlines([], _F, Acc) ->
367+
Acc;
368+
normalize_newlines({errorlist, _}=Errorlist, _F, _Acc) ->
369+
throw(Errorlist);
370+
normalize_newlines({error, _}=Error, _F, _Acc) ->
371+
throw(Error);
372+
normalize_newlines([{KeyList, Value}|Rest], F, Acc0) ->
373+
Acc1 = [{KeyList, F(Value)} | Acc0],
374+
normalize_newlines(Rest, F, Acc1).
375+
376+
multiline_test() ->
377+
Conf = normalize_newlines(file("test/multiline.conf")),
378+
?assertEqual(1, length(Conf)),
379+
?assertMatch([{["a"]," l0\n l1\n l2"}], Conf).
380+
381+
multiline_empty_test() ->
382+
Conf = file("test/multiline_empty.conf"),
383+
?assertEqual(1, length(Conf)),
384+
?assertMatch([{["a"],[]}], Conf).
385+
386+
multiline_same_line_test() ->
387+
Conf = file("test/multiline_same_line.conf"),
388+
?assertEqual(1, length(Conf)),
389+
?assertMatch([{["a"],"value"}], Conf).
390+
391+
multiline_special_chars_test() ->
392+
Conf = normalize_newlines(file("test/multiline_special_chars.conf")),
393+
?assertEqual(1, length(Conf)),
394+
?assertMatch([{["a"], "key = value\n# comment\n'quoted' = \"double\""}], Conf).
395+
396+
multiline_unclosed_test() ->
397+
Conf = file("test/multiline_unclosed.conf"),
398+
?assertMatch({errorlist, [{error, {conf_syntax, {"test/multiline_unclosed.conf", _}}}]}, Conf).
399+
400+
multiline_embedded_delimiter_test() ->
401+
Conf = file("test/multiline_embedded_delimiter.conf"),
402+
?assertMatch({errorlist, [{error, {conf_syntax, {"test/multiline_embedded_delimiter.conf", _}}}]}, Conf).
403+
404+
multiline_with_trailing_comment_test() ->
405+
Conf = normalize_newlines(file("test/multiline_with_trailing_comment.conf")),
406+
?assertEqual(1, length(Conf)),
407+
?assertMatch([{["a"],"value"}], Conf).
408+
409+
multiline_multiple_test() ->
410+
Conf = normalize_newlines(file("test/multiline_multiple.conf")),
411+
?assertEqual(2, length(Conf)),
412+
?assertMatch([{["b"],"l3\nl4\nl5"},
413+
{["a"]," l0\n l1\n l2"}], Conf).
414+
415+
multiline_mixed_test() ->
416+
Conf = normalize_newlines(file("test/multiline_mixed.conf")),
417+
?assertEqual(3, length(Conf)),
418+
?assertMatch([{["another"],"simple"},
419+
{["regular"],"value"},
420+
{["a"]," l0\nl1\n l2"}], Conf).
421+
422+
multiline_whitespace_only_test() ->
423+
Conf = normalize_newlines(file("test/multiline_whitespace_only.conf")),
424+
?assertEqual(1, length(Conf)),
425+
?assertMatch([{["a"]," \t\n\r "}], Conf).
426+
427+
multiline_with_include_test() ->
428+
Conf = normalize_newlines(file("test/multiline_with_include.conf")),
429+
?assertEqual(1, length(Conf)),
430+
?assertMatch([{["a"], "include other.conf\ninclude *.conf"}], Conf).
431+
432+
multiline_eof_test() ->
433+
Conf = file("test/multiline_eof.conf"),
434+
?assertEqual(1, length(Conf)),
435+
?assertMatch([{["a"], "value"}], Conf).
436+
437+
multiline_unicode_test() ->
438+
Conf = normalize_newlines(file("test/multiline_unicode.conf")),
439+
?assertEqual(1, length(Conf)),
440+
?assertMatch([{["greeting"], "Hello 世界! 🌍\nПривіт, світ!\nПривет, мир!\nمرحبا بالعالم\nこんにちは世界"}], Conf).
441+
354442
duplicates_test() ->
355443
Conf = file("test/multi1.conf"),
356444
?assertEqual(2, length(Conf)),

test/multiline.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
a = '''
2+
l0
3+
l1
4+
l2
5+
'''
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
a = '''foo
2+
bar ''' baz
3+
bat
4+
'''

test/multiline_empty.conf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
a = '''
2+
'''

test/multiline_eof.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
a = '''value'''

test/multiline_mixed.conf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
a = '''
2+
l0
3+
l1
4+
l2
5+
'''
6+
7+
regular = value
8+
9+
another = simple

test/multiline_multiple.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
a = '''
2+
l0
3+
l1
4+
l2
5+
'''
6+
7+
b = '''l3
8+
l4
9+
l5
10+
'''

0 commit comments

Comments
 (0)