Skip to content

Commit f7412c0

Browse files
committed
Enhance YAML script formatting with intelligent detection of shell patterns and improved handling for script-related properties
1 parent df32a6d commit f7412c0

File tree

3 files changed

+280
-0
lines changed

3 files changed

+280
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@noxify/gitlab-ci-builder": patch
3+
---
4+
5+
Improve script formatting in YAML import with intelligent detection of shell operators.
6+
7+
The import now intelligently formats `script`, `before_script`, and `after_script` properties:
8+
9+
- **Simple multi-line commands** → Split into string array for better readability
10+
- **Line continuations** (`\`) → Preserved as template literals
11+
- **Shell operators** (heredoc `<<`, pipes `|`, redirects `>`, `>>`, `2>`, `<`) → Preserved as template literals
12+
- **Single-line commands** → Formatted as simple strings
13+
14+
This produces more idiomatic and readable TypeScript code while preserving shell command semantics.

src/import.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,59 @@ export function fromYaml(yamlContent: string): string {
126126
return lines.join("\n")
127127
}
128128

129+
/**
130+
* Format script values (script, before_script, after_script) intelligently.
131+
*
132+
* Detects shell-specific patterns and formats accordingly:
133+
* - Line continuations (\) → Template literal
134+
* - Heredoc (<<) → Template literal
135+
* - Shell operators (|, >, >>, 2>, &>, <) → Template literal
136+
* - Simple multi-line commands → Array of strings
137+
* - Single line → String
138+
*/
139+
function formatScriptValue(value: unknown): string {
140+
if (typeof value !== "string") {
141+
return formatValue(value, 0)
142+
}
143+
144+
// Single line without special characters
145+
if (!value.includes("\n")) {
146+
return JSON.stringify(value)
147+
}
148+
149+
// Check for shell-specific patterns that require keeping as single string
150+
const shellOperatorPatterns = [
151+
/\\\n/, // Line continuation
152+
/<</, // Heredoc
153+
/(?<!\|)\|(?!\|)/, // Pipe (but not ||) - negative lookbehind and lookahead
154+
/>>?/, // Redirect output
155+
/2>/, // Redirect stderr
156+
/&>/, // Redirect both
157+
/(?<!<)<(?!<)/, // Redirect input (but not <<) - negative lookbehind and lookahead
158+
]
159+
160+
const hasShellOperators = shellOperatorPatterns.some((pattern) => pattern.test(value))
161+
162+
if (hasShellOperators) {
163+
// Keep as template literal to preserve exact formatting
164+
// Escape backticks and ${} in the string
165+
const escaped = value.replace(/`/g, "\\`").replace(/\$\{/g, "\\${")
166+
return `\`${escaped}\``
167+
}
168+
169+
// Simple multi-line without shell operators - split into array
170+
const lines = value
171+
.split("\n")
172+
.map((l) => l.trim())
173+
.filter((l) => l.length > 0)
174+
175+
if (lines.length === 1) {
176+
return JSON.stringify(lines[0])
177+
}
178+
179+
return `[${lines.map((l) => JSON.stringify(l)).join(", ")}]`
180+
}
181+
129182
/**
130183
* Format a value as TypeScript code with proper indentation.
131184
*/
@@ -178,6 +231,17 @@ function formatValue(value: unknown, indentLevel: number): string {
178231
const props = entries.map(([k, v]) => {
179232
const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k)
180233

234+
// Special handling for script-related properties
235+
const scriptProperties = ["script", "before_script", "after_script"]
236+
if (scriptProperties.includes(k)) {
237+
// For script arrays, format each element intelligently
238+
if (Array.isArray(v)) {
239+
const formattedScripts = v.map((item) => formatScriptValue(item))
240+
return `${indent}${key}: [${formattedScripts.join(", ")}]`
241+
}
242+
return `${indent}${key}: ${formatScriptValue(v)}`
243+
}
244+
181245
// Special handling for properties that should be strings but might be single-element arrays
182246
// These properties accept string | string[] but single values are more common
183247
const singleValueProperties = ["extends", "annotations", "dotenv"]

tests/script-formatting.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { fromYaml } from "../src/import"
4+
5+
describe("Script formatting", () => {
6+
it("should format simple single-line script as string", () => {
7+
const yaml = `
8+
test:
9+
script:
10+
- echo "hello world"
11+
`
12+
const result = fromYaml(yaml)
13+
expect(result).toContain('script: ["echo \\"hello world\\""]')
14+
})
15+
16+
it("should format simple multi-line commands as array", () => {
17+
const yaml = `
18+
test:
19+
script:
20+
- |
21+
echo "step1"
22+
echo "step2"
23+
echo "step3"
24+
`
25+
const result = fromYaml(yaml)
26+
expect(result).toContain(
27+
'script: [["echo \\"step1\\"", "echo \\"step2\\"", "echo \\"step3\\""]]',
28+
)
29+
})
30+
31+
it("should preserve line continuation with backslash as template literal", () => {
32+
const yaml = `
33+
test:
34+
script:
35+
- |
36+
apk update && \\
37+
apk add --no-cache libc6-compat aws-cli jq && \\
38+
rm -rf /var/cache/apk/*
39+
`
40+
const result = fromYaml(yaml)
41+
expect(result).toContain("script: [`apk update")
42+
expect(result).toContain("apk add --no-cache")
43+
expect(result).toContain("rm -rf /var/cache/apk/*")
44+
expect(result).toContain("`]")
45+
})
46+
47+
it("should preserve heredoc as template literal", () => {
48+
const yaml = `
49+
test:
50+
script:
51+
- |
52+
cat <<EOF
53+
line1
54+
line2
55+
EOF
56+
`
57+
const result = fromYaml(yaml)
58+
expect(result).toContain("script: [`cat <<EOF")
59+
expect(result).toContain("line1")
60+
expect(result).toContain("line2")
61+
expect(result).toContain("EOF")
62+
expect(result).toContain("`]")
63+
})
64+
65+
it("should preserve pipe operators as template literal", () => {
66+
const yaml = `
67+
test:
68+
script:
69+
- |
70+
cat file.txt | grep "pattern" | sort
71+
`
72+
const result = fromYaml(yaml)
73+
expect(result).toContain('script: [`cat file.txt | grep "pattern" | sort')
74+
expect(result).toContain("`]")
75+
})
76+
77+
it("should preserve output redirection as template literal", () => {
78+
const yaml = `
79+
test:
80+
script:
81+
- |
82+
echo "output" > file.txt
83+
echo "append" >> file.txt
84+
command 2> error.log
85+
`
86+
const result = fromYaml(yaml)
87+
expect(result).toContain("script: [`echo")
88+
expect(result).toContain("> file.txt")
89+
expect(result).toContain(">> file.txt")
90+
expect(result).toContain("2> error.log")
91+
expect(result).toContain("`]")
92+
})
93+
94+
it("should handle mixed script array with different formats", () => {
95+
const yaml = `
96+
test:
97+
script:
98+
- echo "simple"
99+
- |
100+
echo "multi1"
101+
echo "multi2"
102+
- |
103+
complex && \\
104+
continuation
105+
`
106+
const result = fromYaml(yaml)
107+
expect(result).toContain('script: ["echo \\"simple\\"",')
108+
expect(result).toContain('["echo \\"multi1\\"", "echo \\"multi2\\""]')
109+
expect(result).toContain("`complex")
110+
})
111+
112+
it("should handle before_script with shell operators", () => {
113+
const yaml = `
114+
test:
115+
before_script:
116+
- |
117+
mkdir ~/.npm-global
118+
export PATH=$PATH:~/.npm-global/bin
119+
120+
`
121+
const result = fromYaml(yaml)
122+
expect(result).toContain("before_script:")
123+
expect(result).toContain('["mkdir ~/.npm-global", "export PATH=$PATH:~/.npm-global/bin"')
124+
})
125+
126+
it("should handle after_script with continuations", () => {
127+
const yaml = `
128+
test:
129+
after_script:
130+
- |
131+
cleanup && \\
132+
remove_temp
133+
`
134+
const result = fromYaml(yaml)
135+
expect(result).toContain("after_script: [`cleanup")
136+
expect(result).toContain("remove_temp")
137+
expect(result).toContain("`]")
138+
})
139+
140+
it("should handle logical operators correctly", () => {
141+
const yaml = `
142+
test:
143+
script:
144+
- |
145+
command1 || echo "fallback"
146+
command2 && echo "success"
147+
`
148+
const result = fromYaml(yaml)
149+
// || and && are logical operators, not pipes - should be split into array
150+
expect(result).toContain(
151+
'["command1 || echo \\"fallback\\"", "command2 && echo \\"success\\""]',
152+
)
153+
})
154+
155+
it("should escape backticks in template literals", () => {
156+
const yaml = `
157+
test:
158+
script:
159+
- |
160+
echo \`date\` > file.txt
161+
`
162+
const result = fromYaml(yaml)
163+
expect(result).toContain("\\`date\\`")
164+
})
165+
166+
it("should escape template expressions in template literals", () => {
167+
const yaml = `
168+
test:
169+
script:
170+
- |
171+
echo "\${VAR}" > file.txt
172+
`
173+
const result = fromYaml(yaml)
174+
expect(result).toContain("\\${VAR}")
175+
})
176+
177+
it("should handle empty lines in multiline scripts", () => {
178+
const yaml = `
179+
test:
180+
script:
181+
- |
182+
echo "line1"
183+
184+
echo "line2"
185+
`
186+
const result = fromYaml(yaml)
187+
// Empty lines should be filtered out when splitting
188+
expect(result).toContain('["echo \\"line1\\"", "echo \\"line2\\""]')
189+
})
190+
191+
it("should handle input redirection", () => {
192+
const yaml = `
193+
test:
194+
script:
195+
- |
196+
command < input.txt
197+
`
198+
const result = fromYaml(yaml)
199+
expect(result).toContain("`command < input.txt")
200+
expect(result).toContain("`]")
201+
})
202+
})

0 commit comments

Comments
 (0)