Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions expressions/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ export enum ErrorType {
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
ErrorUnrecognizedFunction,
ErrorInvalidFormatString,
ErrorFormatArgCountMismatch
}

export class ExpressionError extends Error {
constructor(private typ: ErrorType, private tok: Token) {
super(`${errorDescription(typ)}: '${tokenString(tok)}'`);
constructor(private typ: ErrorType, private tok: Token, customMessage?: string) {
super(customMessage ?? `${errorDescription(typ)}: '${tokenString(tok)}'`);

this.pos = this.tok.range.start;
}
Expand Down Expand Up @@ -46,6 +48,10 @@ function errorDescription(typ: ErrorType): string {
return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction:
return "Unrecognized function";
case ErrorType.ErrorInvalidFormatString:
return "Invalid format string";
case ErrorType.ErrorFormatArgCountMismatch:
return "Format string argument count mismatch";
default: // Should never reach here.
return "Unknown error";
}
Expand Down
3 changes: 2 additions & 1 deletion expressions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ export {Expr} from "./ast.js";
export {complete, CompletionItem} from "./completion.js";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
export * as data from "./data/index.js";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {ErrorType, ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {Evaluator} from "./evaluator.js";
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
export {wellKnownFunctions} from "./funcs.js";
export {Lexer, Result} from "./lexer.js";
export {Parser} from "./parser.js";
export {validateFormatString} from "./validate-format.js";
25 changes: 25 additions & 0 deletions expressions/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
import {ParseContext, validateFunction} from "./funcs.js";
import {FunctionInfo} from "./funcs/info.js";
import {Token, TokenType} from "./lexer.js";
import {validateFormatString} from "./validate-format.js";

export class Parser {
private extContexts: Map<string, boolean>;
Expand Down Expand Up @@ -261,6 +262,30 @@ export class Parser {

validateFunction(this.context, identifier, args.length);

// Validate format() calls
if (identifier.lexeme.toLowerCase() === "format" && args.length > 0) {
const firstArg = args[0];
if (firstArg instanceof Literal && firstArg.literal.kind === data.Kind.String) {
const formatString = firstArg.literal.coerceString();
const result = validateFormatString(formatString);

if (!result.valid) {
throw new ExpressionError(ErrorType.ErrorInvalidFormatString, identifier);
}

// Check argument count: format string uses {0} to {N}, so need N+1 args after format string
const providedArgs = args.length - 1;
const requiredArgs = result.maxArgIndex + 1;
if (requiredArgs > providedArgs) {
throw new ExpressionError(
ErrorType.ErrorFormatArgCountMismatch,
identifier,
`Format string references {${result.maxArgIndex}} but only ${providedArgs} argument(s) provided`
);
}
}
}

return new FunctionCall(identifier, args);
}

Expand Down
63 changes: 63 additions & 0 deletions expressions/src/validate-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {validateFormatString} from "./validate-format.js";

describe("validateFormatString", () => {
it("returns valid for simple placeholder", () => {
const result = validateFormatString("{0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});

it("returns valid for multiple placeholders", () => {
const result = validateFormatString("{0} {1} {2}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});

it("returns valid for text with placeholder", () => {
const result = validateFormatString("hello {0} world");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});

it("returns valid for escaped left braces", () => {
const result = validateFormatString("{{0}} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});

it("returns valid for escaped right braces", () => {
const result = validateFormatString("{0}}}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});

it("returns valid for no placeholders", () => {
const result = validateFormatString("hello world");
expect(result).toEqual({valid: true, maxArgIndex: -1});
});

it("returns invalid for missing closing brace", () => {
const result = validateFormatString("{0");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});

it("returns invalid for empty placeholder", () => {
const result = validateFormatString("{}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});

it("returns invalid for non-numeric placeholder", () => {
const result = validateFormatString("{abc}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});

it("returns invalid for unescaped closing brace", () => {
const result = validateFormatString("text } more");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});

it("handles out-of-order placeholders", () => {
const result = validateFormatString("{2} {0} {1}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});

it("handles repeated placeholders", () => {
const result = validateFormatString("{0} {0} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
});
101 changes: 101 additions & 0 deletions expressions/src/validate-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Format string validation for format() function calls.
* Validates format string syntax and argument count at parse time.
*/

/**
* Validates a format string and returns the maximum placeholder index.
*
* @param formatString The format string to validate
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
*/
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
let maxIndex = -1;
let i = 0;

while (i < formatString.length) {
// Find next left brace
let lbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "{") {
lbrace = j;
break;
}
}

// Find next right brace
let rbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}

// No more braces
if (lbrace < 0 && rbrace < 0) {
break;
}

// Left brace comes first (or only left brace exists)
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
// Check if it's escaped
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
// Escaped left brace
i = lbrace + 2;
continue;
}

// This is a placeholder opening - find the closing brace
rbrace = -1;
for (let j = lbrace + 1; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}

if (rbrace < 0) {
// Missing closing brace
return {valid: false, maxArgIndex: -1};
}

// Validate placeholder content (must be digits only)
if (rbrace === lbrace + 1) {
// Empty placeholder {}
return {valid: false, maxArgIndex: -1};
}

// Parse the index and validate it's all digits
let index = 0;
for (let j = lbrace + 1; j < rbrace; j++) {
const c = formatString[j];
if (c < "0" || c > "9") {
// Non-numeric character
return {valid: false, maxArgIndex: -1};
}
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
}

if (index > maxIndex) {
maxIndex = index;
}

i = rbrace + 1;
continue;
}

// Right brace comes first (or only right brace exists)
// Check if it's escaped
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
// Escaped right brace
i = rbrace + 2;
continue;
}

// Unescaped right brace outside of placeholder
return {valid: false, maxArgIndex: -1};
}

return {valid: true, maxArgIndex: maxIndex};
}
Loading