Skip to content

Commit 8bbd72d

Browse files
authored
strings: add a Builder.indent(s) method to help with #25917 (#25921)
1 parent 8ab0371 commit 8bbd72d

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

‎vlib/strings/builder.c.v‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,149 @@ pub fn (mut b Builder) write_repeated_rune(r rune, count int) {
334334
}
335335
}
336336
}
337+
338+
// IndentParam holds configuration parameters for the indent() function
339+
@[params]
340+
pub struct IndentParam {
341+
pub mut:
342+
block_start rune = `{` // Character that starts a new block (+ indent)
343+
block_end rune = `}` // Character that ends a new block (- indent)
344+
indent_char rune = ` ` // Character used for indentation (space or tab)
345+
indent_count int = 4 // Number of indent_char per indentation level
346+
starting_level int // Initial indentation level (0 = no initial indent)
347+
}
348+
349+
// IndentState represents the current parsing state of the indent() function
350+
enum IndentState {
351+
normal // Normal state, processing regular characters
352+
in_string // Inside a string literal, ignoring formatting characters
353+
}
354+
355+
// indent formats a string by applying structured indentation based on block delimiters.
356+
// It processes the input string `s` and writes the formatted output to the `Builder` `b`.
357+
// The function preserves content inside string literals (both single and double quotes) and
358+
// configures indentation behavior through the `param` structure.
359+
//
360+
// Key behaviors:
361+
// 1. Removes existing indentation at the beginning of lines.
362+
// 2. Applies new indentation based on block nesting levels.
363+
// 3. Ignores block delimiters and formatting characters inside string literals.
364+
// 4. Keeps empty blocks (e.g., {}) on the same line.
365+
// 5. Inserts newlines after `block_start` and before `block_end` (except for empty blocks).
366+
// 6. Maintains existing line breaks from the input.
367+
//
368+
// Example:
369+
// ```v
370+
// import strings
371+
// input := 'User{name:"John" settings:{theme:"dark"}}'
372+
// mut b := strings.new_builder(64)
373+
// b.indent(input, indent_count: 2)
374+
// println(b.str()) // Formatted output: 'User{\n name:"John" settings:{\n theme:"dark"\n }\n}'
375+
// ```
376+
@[direct_array_access]
377+
pub fn (mut b Builder) indent(s string, param IndentParam) {
378+
if s.len == 0 {
379+
return
380+
}
381+
382+
mut state := IndentState.normal
383+
mut indent_level := param.starting_level
384+
mut string_char := `\0`
385+
mut at_line_start := true
386+
for i := 0; i < s.len; i++ {
387+
c := s[i]
388+
match state {
389+
// Normal state: process characters outside of string literals
390+
.normal {
391+
match c {
392+
`"`, `'` { // Note: quote characters for editor display "
393+
state = .in_string
394+
string_char = c
395+
// Add indentation if at the start of a line
396+
if at_line_start {
397+
b.write_repeated_rune(param.indent_char, indent_level * param.indent_count)
398+
at_line_start = false
399+
}
400+
// Write the opening quote
401+
b.write_rune(c)
402+
}
403+
param.block_start {
404+
// Start of a new block
405+
// Add indentation if at the start of a line
406+
if at_line_start {
407+
b.write_repeated_rune(param.indent_char, indent_level * param.indent_count)
408+
at_line_start = false
409+
}
410+
411+
// Write the block start character
412+
b.write_rune(c)
413+
414+
// Check for empty block (e.g., {})
415+
// Empty blocks stay on the same line
416+
if i + 1 < s.len && s[i + 1] == param.block_end {
417+
b.write_rune(param.block_end)
418+
i++
419+
} else {
420+
// Non-empty block: increase indentation and add newline
421+
indent_level++
422+
b.write_rune(`\n`)
423+
at_line_start = true
424+
}
425+
}
426+
param.block_end {
427+
// End of a block
428+
// Decrease indentation level (but not below 0)
429+
if indent_level > 0 {
430+
indent_level--
431+
}
432+
433+
// If not at the start of a line, add a newline
434+
if !at_line_start {
435+
b.write_rune(`\n`)
436+
}
437+
438+
// Add indentation for the block end
439+
b.write_repeated_rune(param.indent_char, indent_level * param.indent_count)
440+
at_line_start = false
441+
442+
b.write_rune(c)
443+
}
444+
` `, `\t`, `\r`, `\n` {
445+
// Whitespace characters
446+
// Only write whitespace if not at the start of a line
447+
if !at_line_start {
448+
b.write_rune(c)
449+
}
450+
451+
// Newline resets the line start flag
452+
if c == `\n` {
453+
at_line_start = true
454+
}
455+
}
456+
else {
457+
// Any other character
458+
// Add indentation if at the start of a line
459+
if at_line_start {
460+
b.write_repeated_rune(param.indent_char, indent_level * param.indent_count)
461+
at_line_start = false
462+
}
463+
b.write_rune(c)
464+
}
465+
}
466+
}
467+
.in_string {
468+
// Inside a string literal: preserve all characters as-is
469+
b.write_rune(c)
470+
471+
// Check for string termination
472+
// The character must match the opening quote and not be escaped
473+
if c == string_char {
474+
if s[i - 1] != `\\` {
475+
state = .normal
476+
string_char = `\0`
477+
}
478+
}
479+
}
480+
}
481+
}
482+
}

‎vlib/strings/builder_test.v‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,213 @@ fn test_write_repeated_rune() {
202202
x := sb.str()
203203
assert x == 'hhhhhwwwww√√√√√ '
204204
}
205+
206+
struct IndentTest {
207+
param strings.IndentParam
208+
input string
209+
output string
210+
}
211+
212+
// vfmt off
213+
const indent_test_data = [
214+
IndentTest{
215+
input: 'User1 {
216+
name: "John"
217+
settings: {
218+
theme: "dark"
219+
language: "en"
220+
}
221+
}'
222+
output: 'User1 {
223+
name: "John"
224+
settings: {
225+
theme: "dark"
226+
language: "en"
227+
}
228+
}'
229+
},
230+
IndentTest{
231+
input: 'User2{name:"John" settings:{theme:"dark" language:"en" hobbies:["reading","sports"]}}'
232+
output: 'User2{
233+
name:"John" settings:{
234+
theme:"dark" language:"en" hobbies:["reading","sports"]
235+
}
236+
}'
237+
},
238+
IndentTest{
239+
input: 'message {text: "Hello {world}!" count: 5 nested: {data: "Test {inner}"}}'
240+
output: 'message {
241+
text: "Hello {world}!" count: 5 nested: {
242+
data: "Test {inner}"
243+
}
244+
}'
245+
},
246+
IndentTest{
247+
input: 'if x > 0 {println("Positive") for i in 0..x {println(i) if i % 2 == 0 {println("even")}}} else {println("Not positive")}'
248+
output: 'if x > 0 {
249+
println("Positive") for i in 0..x {
250+
println(i) if i % 2 == 0 {
251+
println("even")
252+
}
253+
}
254+
} else {
255+
println("Not positive")
256+
}'
257+
},
258+
IndentTest{
259+
input: 'config{database:{host:"localhost" port:5432} server:{port:8080 routes:{api:"/api" static:"/static"}} log:{level:"info"}}'
260+
output: 'config{
261+
database:{
262+
host:"localhost" port:5432
263+
} server:{
264+
port:8080 routes:{
265+
api:"/api" static:"/static"
266+
}
267+
} log:{
268+
level:"info"
269+
}
270+
}'
271+
},
272+
IndentTest{
273+
input: "MyS{
274+
a: 0
275+
b: 0
276+
son: Son{
277+
k: ''
278+
m: ''
279+
}
280+
}
281+
"
282+
output: "MyS{
283+
a: 0
284+
b: 0
285+
son: Son{
286+
k: ''
287+
m: ''
288+
}
289+
}
290+
"
291+
},
292+
IndentTest{
293+
input: 'config {message: "He said: \\"Hello World!\\"" code: "if (x) { return \\"value\\"; }"}'
294+
output: 'config {
295+
message: "He said: \\"Hello World!\\"" code: "if (x) { return \\"value\\"; }"
296+
}'
297+
},
298+
IndentTest {
299+
input : 'data {text: "String with {curly braces} and \\"quotes\\"" nested: {value: "Inner \\"quoted\\" string"}}'
300+
output : 'data {
301+
text: "String with {curly braces} and \\"quotes\\"" nested: {
302+
value: "Inner \\"quoted\\" string"
303+
}
304+
}'
305+
},
306+
IndentTest {
307+
input : 'escape {backslash: "Path: C:\\\\Users\\\\Test" newline: "Line1\\nLine2" tab: "Column1\\tColumn2"}'
308+
output : 'escape {
309+
backslash: "Path: C:\\\\Users\\\\Test" newline: "Line1\\nLine2" tab: "Column1\\tColumn2"
310+
}'
311+
},
312+
IndentTest {
313+
input : 'nested {outer: "Outer \\"quote\\" with {braces}" inner: {value: "Inner string with \\\\backslash and \\"quotes\\""}}'
314+
output : 'nested {
315+
outer: "Outer \\"quote\\" with {braces}" inner: {
316+
value: "Inner string with \\\\backslash and \\"quotes\\""
317+
}
318+
}'
319+
},
320+
IndentTest {
321+
input : 'complex {str1: "\\"Escaped quotes\\"" str2: "Backslash: \\\\" str3: "Mixed: \\"quoted\\" and \\\\backslash" regex: "Pattern: \\\\d+\\\\.\\\\d+"}'
322+
output : 'complex {
323+
str1: "\\"Escaped quotes\\"" str2: "Backslash: \\\\" str3: "Mixed: \\"quoted\\" and \\\\backslash" regex: "Pattern: \\\\d+\\\\.\\\\d+"
324+
}'
325+
},
326+
IndentTest {
327+
input : 'json {data: "{\\"name\\": \\"John\\", \\"age\\": 30, \\"city\\": \\"New York\\"}"}'
328+
output : 'json {
329+
data: "{\\"name\\": \\"John\\", \\"age\\": 30, \\"city\\": \\"New York\\"}"
330+
}'
331+
},
332+
IndentTest {
333+
input : 'code {function: "fn test() { if (x) { return \\\"value\\\"; } }" error: "Error: \\\"File not found\\\" at line 10"}'
334+
output : 'code {
335+
function: "fn test() { if (x) { return \\\"value\\\"; } }" error: "Error: \\\"File not found\\\" at line 10"
336+
}'
337+
},
338+
IndentTest {
339+
input : 'multiline {message: "Line 1\\nLine 2\\nLine 3 with {braces}\\nLine 4 with \\\"quotes\\\""}'
340+
output : 'multiline {
341+
message: "Line 1\\nLine 2\\nLine 3 with {braces}\\nLine 4 with \\\"quotes\\\""
342+
}'
343+
},
344+
IndentTest {
345+
input : 'extreme {str: "\\\\\\\\\\\\\\"Quadruple escaped\\\\\\\\\\\\\\""}'
346+
output : 'extreme {
347+
str: "\\\\\\\\\\\\\\"Quadruple escaped\\\\\\\\\\\\\\""
348+
}'
349+
},
350+
IndentTest{
351+
param: strings.IndentParam {
352+
block_start: `[`
353+
block_end: `]`
354+
}
355+
input: 'message [text: "Hello {world}!" count: 5 nested: [data: "Test {inner}"]]'
356+
output: 'message [
357+
text: "Hello {world}!" count: 5 nested: [
358+
data: "Test {inner}"
359+
]
360+
]'
361+
},
362+
IndentTest{
363+
param: strings.IndentParam {
364+
block_start: `[`
365+
block_end: `]`
366+
indent_char: `#`
367+
}
368+
input: 'message [text: "Hello {world}!" count: 5 nested: [data: "Test {inner}"]]'
369+
output: 'message [
370+
####text: "Hello {world}!" count: 5 nested: [
371+
########data: "Test {inner}"
372+
####]
373+
]'
374+
},
375+
IndentTest{
376+
param: strings.IndentParam {
377+
block_start: `[`
378+
block_end: `]`
379+
indent_char: `\t`
380+
indent_count: 2
381+
}
382+
input: 'message [text: "Hello {world}!" count: 5 nested: [data: "Test {inner}"]]'
383+
output: 'message [
384+
\t\ttext: "Hello {world}!" count: 5 nested: [
385+
\t\t\t\tdata: "Test {inner}"
386+
\t\t]
387+
]'
388+
},
389+
IndentTest{
390+
param: strings.IndentParam {
391+
block_start: `[`
392+
block_end: `]`
393+
indent_char: `#`
394+
indent_count: 1
395+
starting_level: 2
396+
}
397+
input: 'message [text: "Hello {world}!" count: 5 nested: [data: "Test {inner}"]]'
398+
output: '##message [
399+
###text: "Hello {world}!" count: 5 nested: [
400+
####data: "Test {inner}"
401+
###]
402+
##]'
403+
},
404+
]
405+
406+
// vfmt on
407+
408+
fn test_indent() {
409+
for t in indent_test_data {
410+
mut sb := strings.new_builder(256)
411+
sb.indent(t.input, t.param)
412+
assert sb.str() == t.output
413+
}
414+
}

0 commit comments

Comments
 (0)