diff --git a/README.md b/README.md index 34491de..d615de7 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ render("./output", { | `<@if(context.x)>` | Conditional block — boolean check (must end with `<@endif>`) | | `<@if(eq(context.x,"value"))>` | Conditional block — string equality check | | `<@if(neq(context.x,"value"))>` | Conditional block — string inequality check | +| `<@if(and(context.x,eq(context.y,"value")))>` | Conditional block — all child conditions must match | +| `<@if(or(context.x,eq(context.y,"value")))>` | Conditional block — any child condition may match | | `<@elseif(context.y)>` | Else-if branch (same forms as `@if`) | | `<@else>` | Else branch | | `<@endif>` | End conditional block | @@ -74,6 +76,8 @@ render("./output", { | `<@if(context.x)>dirname` | Conditionally include directory/file (boolean check) | | `<@if(eq(context.x,"value"))>dirname` | Conditionally include by string equality | | `<@if(neq(context.x,"value"))>dirname` | Conditionally include by string inequality | +| `<@if(and(context.x,eq(context.y,"value")))>dirname` | Conditionally include by combined conditions | +| `<@if(or(context.x,eq(context.y,"value")))>dirname` | Conditionally include by alternate conditions | | `<@var(context.x)>` | Dynamic directory/file name | These can be combined: `<@if(context.web.create)><@var(context.web.dir)>` creates a directory named by `context.web.dir` only if `context.web.create` is true. diff --git a/index.test.ts b/index.test.ts index fd95a70..6372009 100644 --- a/index.test.ts +++ b/index.test.ts @@ -590,3 +590,68 @@ test("if neq in path", () => { render(tmp,{test:"test"}) expect(existsSync(join(tmp,"not-test"))).toBe(false) }) + +test("if neq multiline block", () => { + const createRenderer = initRenderer("./testdata/neq_multiline_block") + const render = createRenderer(z.object({ + project: z.object({ + frontend: z.string() + }) + })) + + render(tmp,{project:{frontend:"react"}}) + expect(readFileSync(join(tmp,"test.txt"), "utf-8")).toContain("bun dev") + + render(tmp,{project:{frontend:"none"}}) + expect(readFileSync(join(tmp,"test.txt"), "utf-8")).not.toContain("bun dev") +}) + +test("and or in file", () => { + const createRenderer = initRenderer("./testdata/and_or_in_file") + expect(() => createRenderer(z.object({ + enabled: z.boolean(), + fallback: z.boolean(), + kind: z.boolean() + }))).toThrow(SchemaMismatchError) + + const render = createRenderer(z.object({ + enabled: z.boolean(), + fallback: z.boolean(), + kind: z.string() + })) + + render(tmp,{enabled:true, fallback:false, kind:"web"}) + let content = readFileSync(join(tmp,"test.txt"), "utf-8") + expect(content).toContain("and") + expect(content).toContain("or") + expect(content).toContain("nested") + + render(tmp,{enabled:false, fallback:true, kind:"web"}) + content = readFileSync(join(tmp,"test.txt"), "utf-8") + expect(content).toContain("not-and") + expect(content).toContain("not-or") + expect(content).toContain("nested") + + render(tmp,{enabled:false, fallback:true, kind:"docs"}) + content = readFileSync(join(tmp,"test.txt"), "utf-8") + expect(content).toContain("not-and") + expect(content).toContain("or") + expect(content).toContain("nested") +}) + +test("and or in path", () => { + const createRenderer = initRenderer("./testdata/and_or_in_path") + const render = createRenderer(z.object({ + enabled: z.boolean(), + kind: z.string() + })) + + render(tmp,{enabled:true, kind:"web"}) + expect(existsSync(join(tmp,"match"))).toBe(true) + + render(tmp,{enabled:true, kind:"docs"}) + expect(existsSync(join(tmp,"match"))).toBe(true) + + render(tmp,{enabled:false, kind:"docs"}) + expect(existsSync(join(tmp,"match"))).toBe(false) +}) diff --git a/parser.ts b/parser.ts index 3a2f25a..61b46f7 100644 --- a/parser.ts +++ b/parser.ts @@ -9,12 +9,118 @@ export type TemplateVariable = { const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g -const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/ const PATH_RE = /^context\.(.+)$/ +type DirectiveToken = { + type: "if" | "elseif" | "else" | "endif" + condition?: string +} + +function readCondition(text: string, start: number): { condition: string; end: number } | null { + let depth = 0 + let inString = false + let escaped = false + + for (let i = start; i < text.length; i++) { + const char = text[i]! + if (escaped) { + escaped = false + continue + } + if (char === "\\") { + escaped = true + continue + } + if (char === "\"") { + inString = !inString + continue + } + if (inString) continue + if (char === "(") { + depth += 1 + continue + } + if (char === ")") { + depth -= 1 + if (depth === 0 && text[i + 1] === ">") { + return { condition: text.slice(start + 1, i), end: i + 2 } + } + } + } + + return null +} + +function getDirectiveTokens(text: string): DirectiveToken[] { + const tokens: DirectiveToken[] = [] + for (let i = 0; i < text.length; i++) { + if (text[i] !== "<" || text[i + 1] !== "@") continue + const rest = text.slice(i + 2) + const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined + if (!type) continue + const afterName = i + 2 + type.length + if ((type === "if" || type === "elseif") && text[afterName] === "(") { + const parsed = readCondition(text, afterName) + if (!parsed) continue + tokens.push({ type, condition: parsed.condition }) + i = parsed.end - 1 + } else if ((type === "else" || type === "endif") && text[afterName] === ">") { + tokens.push({ type }) + i = afterName + } + } + return tokens +} + +function splitArgs(args: string): string[] { + const result: string[] = [] + let current = "" + let depth = 0 + let inString = false + let escaped = false + + for (const char of args) { + if (escaped) { + current += char + escaped = false + continue + } + if (char === "\\") { + current += char + escaped = true + continue + } + if (char === "\"") { + current += char + inString = !inString + continue + } + if (!inString && char === "(") depth += 1 + if (!inString && char === ")") depth -= 1 + if (!inString && depth === 0 && char === ",") { + result.push(current.trim()) + current = "" + continue + } + current += char + } + + if (current.trim() !== "") result.push(current.trim()) + return result +} + function extractCondition(expr: string | undefined, vars: TemplateVariable[]) { if (!expr) throw new Error("Missing condition expression") + for (const operator of ["and", "or"]) { + const prefix = `${operator}(` + if (expr.startsWith(prefix) && expr.endsWith(")")) { + const args = splitArgs(expr.slice(prefix.length, -1)) + if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`) + for (const arg of args) extractCondition(arg, vars) + return + } + } const stringCompareMatch = expr.match(STRING_COMPARE_RE) if (stringCompareMatch) { vars.push({ path: stringCompareMatch[1]!, type: "string" }) @@ -29,8 +135,10 @@ function extractCondition(expr: string | undefined, vars: TemplateVariable[]) { } function extractFromString(text: string, vars: TemplateVariable[]) { - for (const match of text.matchAll(IF_RE)) { - extractCondition(match[1]!, vars) + for (const token of getDirectiveTokens(text)) { + if (token.type === "if" || token.type === "elseif") { + extractCondition(token.condition, vars) + } } for (const match of text.matchAll(VAR_RE)) { vars.push({ path: match[1]!, type: match[2] ?? "string" }) @@ -40,9 +148,9 @@ function extractFromString(text: string, vars: TemplateVariable[]) { function validateIfBlocks(content: string, vars: TemplateVariable[]) { const stack: { sawElse: boolean }[] = [] - for (const match of content.matchAll(DIRECTIVE_RE)) { - const directive = match[1]! - const condition = match[2] + for (const token of getDirectiveTokens(content)) { + const directive = token.type + const condition = token.condition if (directive === "if") { extractCondition(condition!, vars) diff --git a/render.ts b/render.ts index 12cbec5..aff4061 100644 --- a/render.ts +++ b/render.ts @@ -13,7 +13,6 @@ import { TextDecoder } from "node:util" const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/ const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g -const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g const STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/ const PATH_RE = /^context\.(.+)$/ @@ -66,8 +65,55 @@ type RenderState = { manifest?: ReverseMapManifest } +function splitArgs(args: string): string[] { + const result: string[] = [] + let current = "" + let depth = 0 + let inString = false + let escaped = false + + for (const char of args) { + if (escaped) { + current += char + escaped = false + continue + } + if (char === "\\") { + current += char + escaped = true + continue + } + if (char === "\"") { + current += char + inString = !inString + continue + } + if (!inString && char === "(") depth += 1 + if (!inString && char === ")") depth -= 1 + if (!inString && depth === 0 && char === ",") { + result.push(current.trim()) + current = "" + continue + } + current += char + } + + if (current.trim() !== "") result.push(current.trim()) + return result +} + function evalCondition(expr: string | undefined, context: Record): boolean { if (!expr) throw new Error("Missing condition expression") + for (const operator of ["and", "or"] as const) { + const prefix = `${operator}(` + if (expr.startsWith(prefix) && expr.endsWith(")")) { + const args = splitArgs(expr.slice(prefix.length, -1)) + if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`) + return operator === "and" + ? args.every(arg => evalCondition(arg, context)) + : args.some(arg => evalCondition(arg, context)) + } + } const stringCompareMatch = expr.match(STRING_COMPARE_RE) if (stringCompareMatch) { const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3] @@ -209,6 +255,41 @@ type DirectiveToken = { end: number } +function readDirectiveCondition(text: string, start: number): { condition: string; end: number } | null { + let depth = 0 + let inString = false + let escaped = false + + for (let i = start; i < text.length; i++) { + const char = text[i]! + if (escaped) { + escaped = false + continue + } + if (char === "\\") { + escaped = true + continue + } + if (char === "\"") { + inString = !inString + continue + } + if (inString) continue + if (char === "(") { + depth += 1 + continue + } + if (char === ")") { + depth -= 1 + if (depth === 0 && text[i + 1] === ">") { + return { condition: text.slice(start + 1, i), end: i + 2 } + } + } + } + + return null +} + type TextNode = { type: "text" text: string @@ -246,12 +327,24 @@ type ConditionalRender = { } function getDirectiveTokens(content: string): DirectiveToken[] { - return Array.from(content.matchAll(DIRECTIVE_RE), match => ({ - type: match[1] as DirectiveToken["type"], - condition: match[2], - index: match.index!, - end: match.index! + match[0].length, - })) + const tokens: DirectiveToken[] = [] + for (let i = 0; i < content.length; i++) { + if (content[i] !== "<" || content[i + 1] !== "@") continue + const rest = content.slice(i + 2) + const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined + if (!type) continue + const afterName = i + 2 + type.length + if ((type === "if" || type === "elseif") && content[afterName] === "(") { + const parsed = readDirectiveCondition(content, afterName) + if (!parsed) continue + tokens.push({ type, condition: parsed.condition, index: i, end: parsed.end }) + i = parsed.end - 1 + } else if ((type === "else" || type === "endif") && content[afterName] === ">") { + tokens.push({ type, index: i, end: afterName + 1 }) + i = afterName + } + } + return tokens } function parseNodes( diff --git a/testdata/and_or_in_file/test.txt b/testdata/and_or_in_file/test.txt new file mode 100644 index 0000000..7933be3 --- /dev/null +++ b/testdata/and_or_in_file/test.txt @@ -0,0 +1,3 @@ +<@if(and(context.enabled,eq(context.kind,"web")))>and<@else>not-and<@endif> +<@if(or(context.enabled,neq(context.kind,"web")))>or<@else>not-or<@endif> +<@if(and(or(context.enabled,context.fallback),neq(context.kind,"native")))>nested<@else>not-nested<@endif> diff --git "a/testdata/and_or_in_path/<@if(and(context.enabled,or(eq(context.kind,\"web\"),eq(context.kind,\"docs\"))))>match" "b/testdata/and_or_in_path/<@if(and(context.enabled,or(eq(context.kind,\"web\"),eq(context.kind,\"docs\"))))>match" new file mode 100644 index 0000000..02b1d1a --- /dev/null +++ "b/testdata/and_or_in_path/<@if(and(context.enabled,or(eq(context.kind,\"web\"),eq(context.kind,\"docs\"))))>match" @@ -0,0 +1 @@ +match diff --git a/testdata/neq_multiline_block/test.txt b/testdata/neq_multiline_block/test.txt new file mode 100644 index 0000000..2caa8cb --- /dev/null +++ b/testdata/neq_multiline_block/test.txt @@ -0,0 +1,7 @@ +<@if(neq(context.project.frontend,"none"))> +bundev = { + exec = "bun dev"; + cwd = "./apps/web"; + after= ["devenv:processes:air@started"]; +}; +<@endif>