@kstory/core
Version:
Core parser and lexer for KStory interactive fiction language
4 lines • 108 kB
Source Map (JSON)
{
"version": 3,
"sources": ["../src/token.ts", "../src/tokenFactory.ts", "../src/lexer.ts", "../src/index.ts", "../src/parser.ts", "../src/utils/printToken.ts", "../src/validator.ts"],
"sourcesContent": ["/** biome-ignore-all lint/style/useNamingConvention: <FOR ENUM> */\nexport const TokenTypes = {\n EOF: 'EOF',\n INDENT: 'INDENT',\n DEDENT: 'DEDENT',\n NEWLINE: 'NEWLINE',\n\n COMMENT: 'COMMENT', // # comment\n COMMENT_CONTENT: 'COMMENT_CONTENT', // comment content\n\n REPLICA_BEGIN: 'REPLICA_BEGIN', // \"\n REPLICA_END: 'REPLICA_END',\n\n COMMENT_MULTILINE_BEGIN: 'COMMENT_MULTILINE_BEGIN', // /*\n COMMENT_MULTILINE_END: 'COMMENT_MULTILINE_END', // */\n\n GOTO: 'GOTO', // => | ->\n\n TAG: 'TAG', // @tag\n TAG_VALUE: 'TAG_VALUE', // @tag value\n\n SECTION: 'SECTION', // ==\n IDENTIFIER: 'IDENTIFIER', // == name | @name\n\n CHOICE: 'CHOICE', // +\n CHOICE_TAG: 'CHOICE_TAG', // @@tag\n CHOICE_TEXT: 'CHOICE_TEXT', // choice text content\n CHOICE_TEXT_BOUND: 'CHOICE_TEXT_BOUND', // ```\n\n // values\n BOOLEAN: 'BOOLEAN', // false / true\n INT: 'INT', // 12\n FLOAT: 'FLOAT', // 12.2\n STRING: 'STRING', // Text\n\n ERROR: 'ERROR', // for unknown tokens\n\n // function calls\n CALL: 'CALL', // @call:functionName()\n CALL_ARGUMENT: 'CALL_ARGUMENT', // function parameter\n} as const;\n\nexport type TokenType = (typeof TokenTypes)[keyof typeof TokenTypes];\n\nexport interface Token<T = never> {\n readonly type: TokenType;\n readonly value?: T;\n // Optional source position (line and column) for better diagnostics\n readonly line?: number;\n readonly column?: number;\n readonly endLine?: number;\n readonly endColumn?: number;\n}\n", "import { type Token, TokenTypes } from './token';\n\nexport function commentToken(value: string): Token<string> {\n return {\n type: TokenTypes.COMMENT,\n value: value,\n };\n}\n\nexport function commentContentToken(value: string): Token<string> {\n return {\n type: TokenTypes.COMMENT_CONTENT,\n value: value,\n };\n}\n\nexport function multiCommentBeginToken(): Token<string> {\n return {\n type: TokenTypes.COMMENT_MULTILINE_BEGIN,\n value: '/*',\n };\n}\n\nexport function multiCommentEndToken(): Token<string> {\n return {\n type: TokenTypes.COMMENT_MULTILINE_END,\n value: '*/',\n };\n}\n\nexport function newLineToken(): Token<string> {\n return {\n type: TokenTypes.NEWLINE,\n value: '\\n',\n };\n}\n\nexport function indentToken(): Token {\n return {\n type: TokenTypes.INDENT,\n };\n}\n\nexport function dedentToken(): Token {\n return {\n type: TokenTypes.DEDENT,\n };\n}\n\nexport function eofToken(): Token {\n return {\n type: TokenTypes.EOF,\n };\n}\n\nexport function replicaBeginToken(): Token<string> {\n return {\n type: TokenTypes.REPLICA_BEGIN,\n value: '\" ',\n };\n}\n\nexport function replicaEndToken(): Token {\n return {\n type: TokenTypes.REPLICA_END,\n };\n}\n\nexport function stringToken(value: string): Token<string> {\n return {\n type: TokenTypes.STRING,\n value,\n };\n}\n\nexport function choiceToken(val: string): Token<string> {\n return {\n type: TokenTypes.CHOICE,\n value: val,\n };\n}\n\nexport function choiceTextBoundToken(silent = false): Token<string> {\n return {\n type: TokenTypes.CHOICE_TEXT_BOUND,\n value: silent ? '' : '```',\n };\n}\n\nexport function choiceTextToken(value: string): Token<string> {\n return {\n type: TokenTypes.CHOICE_TEXT,\n value: value,\n };\n}\n\nexport function choiceTagToken(val: string): Token<string> {\n return {\n type: TokenTypes.CHOICE_TAG,\n value: val,\n };\n}\n\nexport function tagToken(val: string): Token<string> {\n return {\n type: TokenTypes.TAG,\n value: val,\n };\n}\n\nexport function tagValueToken(val: string): Token<string> {\n return {\n type: TokenTypes.TAG_VALUE,\n value: val,\n };\n}\n\nexport function sectionToken(): Token<string> {\n return {\n type: TokenTypes.SECTION,\n value: '== ',\n };\n}\n\nexport function identifierToken(val: string): Token<string> {\n return {\n type: TokenTypes.IDENTIFIER,\n value: val,\n };\n}\n\nexport function gotoToken(val: string): Token<string> {\n return {\n type: TokenTypes.GOTO,\n value: val,\n };\n}\n\nexport function errorToken(val: string): Token<string> {\n return {\n type: TokenTypes.ERROR,\n value: val,\n };\n}\n\nexport function callToken(functionName: string): Token<string> {\n return {\n type: TokenTypes.CALL,\n value: functionName,\n };\n}\n\nexport function callArgumentToken(argument: string): Token<string> {\n return {\n type: TokenTypes.CALL_ARGUMENT,\n value: argument,\n };\n}\n", "import { type Token, type TokenType, TokenTypes } from './token';\n\n// Extended token interface with position properties\ninterface TokenWithPosition extends Token<unknown> {\n line?: number;\n column?: number;\n endLine?: number;\n endColumn?: number;\n}\n\nimport {\n callArgumentToken,\n callToken,\n choiceTagToken,\n choiceTextBoundToken,\n choiceTextToken,\n choiceToken,\n commentContentToken,\n commentToken,\n dedentToken,\n eofToken,\n gotoToken,\n identifierToken,\n indentToken,\n multiCommentBeginToken,\n multiCommentEndToken,\n newLineToken,\n replicaBeginToken,\n replicaEndToken,\n sectionToken,\n stringToken,\n tagToken,\n tagValueToken,\n} from './tokenFactory';\n\nconst INDENT_WIDTH = 2;\nconst CALL_PREFIX_LENGTH = 6; // @call:\nconst MULTI_COMMENT_START_LENGTH = 2; // /*\nconst MULTI_COMMENT_END_LENGTH = 2; // */\nconst CHOICE_PREFIX_LENGTH = 2; // +\nconst REPLICA_PREFIX_LENGTH = 2; // \"\nconst SECTION_PREFIX_LENGTH = 3; // ==\nconst GOTO_PREFIX_LENGTH = 3; // -> or =>\nconst CHOICE_TEXT_BOUND_LENGTH = 3; // ```\nconst INLINE_CALL_PREFIX_LENGTH = 6; // {call:\n\nexport class Lexer {\n private position = 0;\n private tokens: Token<unknown>[] = [];\n private curChar: string | undefined = undefined;\n private curLine = 1;\n private curColumn = 1;\n private prevIndent = 0;\n private isInComment = false;\n private isInChoiceText = false;\n private isExtendingStr = false;\n private lastStringTokenIndex = 0;\n // Start position of the current STRING buffer (for precise text token spans)\n private stringStartLine = 1;\n private stringStartColumn = 1;\n\n public constructor(private source: string) {\n this.curChar = this.source[0];\n }\n\n public getTokens() {\n return this.tokens;\n }\n\n private step(times = 1) {\n for (let i = 0; i < times; i++) {\n const prevChar = this.curChar;\n this.position++;\n this.curChar = this.source[this.position];\n\n if (prevChar === '\\n') {\n this.curLine++;\n this.curColumn = 1;\n } else {\n this.curColumn++;\n }\n }\n }\n\n public process() {\n let iterations = 0;\n const maxIterations = 100000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && iterations < maxIterations) {\n this.handleCurrentToken();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.error(\n 'Error: Lexer exceeded max iterations, possible infinite loop detected'\n );\n console.error(\n `Last position: ${this.position}, line: ${this.curLine}, column: ${this.curColumn}, char: '${this.curChar}'`\n );\n throw new Error(\n `Lexer exceeded max iterations at position ${this.position}, line ${this.curLine}, column ${this.curColumn}`\n );\n }\n\n this.push(eofToken());\n }\n\n // Push a token stamped with the current cursor position (both start and end)\n private push(token: Token<unknown>) {\n (token as TokenWithPosition).line = this.curLine;\n (token as TokenWithPosition).column = this.curColumn;\n (token as TokenWithPosition).endLine = this.curLine;\n (token as TokenWithPosition).endColumn = this.curColumn;\n this.tokens.push(token);\n }\n\n // Push a token with an explicit start position; end is the current cursor\n private pushAt(token: Token<unknown>, line: number, column: number) {\n (token as TokenWithPosition).line = line;\n (token as TokenWithPosition).column = column;\n (token as TokenWithPosition).endLine = this.curLine;\n (token as TokenWithPosition).endColumn = this.curColumn;\n this.tokens.push(token);\n }\n\n // Insert a token at an index, stamping with the current cursor position\n private insertTokenAt(index: number, token: Token<unknown>) {\n (token as TokenWithPosition).line = this.curLine;\n (token as TokenWithPosition).column = this.curColumn;\n (token as TokenWithPosition).endLine = this.curLine;\n (token as TokenWithPosition).endColumn = this.curColumn;\n this.tokens.splice(index, 0, token);\n }\n\n private handleCurrentToken() {\n this.handleNewLine();\n this.handleIndent();\n this.handleComment();\n\n if (this.isInComment) return;\n\n this.handleNonCommentTokens();\n this.handleErrorIfNeeded();\n }\n\n private handleNonCommentTokens() {\n if (!this.isExtendingStr && !this.isInChoiceText) {\n this.handleStructuralTokens();\n }\n this.handleChoiceText();\n this.handleString();\n }\n\n private handleStructuralTokens() {\n this.handleCall();\n this.handleChoiceTag();\n this.handleTag();\n this.handleSection();\n this.handleGoto();\n this.handleChoice();\n }\n\n private handleErrorIfNeeded() {\n if (!this.isInComment && !this.isExtendingStr && !this.isInChoiceText) {\n this.handleError();\n }\n }\n\n private handleGoto() {\n if (!this.isGoto()) {\n return;\n }\n\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n const content = `${this.curChar}> `;\n\n // skipping over the '-> '\n this.step(GOTO_PREFIX_LENGTH);\n this.pushAt(gotoToken(content), startLine, startColumn);\n\n this.handleIdentifier();\n }\n\n private handleIdentifier() {\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n const content = this.readLineUntilComment();\n\n // \u041D\u0435 \u0434\u0435\u043B\u0430\u0435\u043C \u0434\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0439 step(), \u0442\u0430\u043A \u043A\u0430\u043A readLineUntilComment() \u0443\u0436\u0435 \u043F\u0440\u043E\u0434\u0432\u0438\u0433\u0430\u0435\u0442 \u043F\u043E\u0437\u0438\u0446\u0438\u044E\n this.pushAt(identifierToken(content), startLine, startColumn);\n }\n\n private handleError() {\n let content = '';\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (!this.getTokenType() && iterations < maxIterations) {\n if (this.curChar === undefined) {\n break;\n }\n\n content += this.curChar;\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: handleError() exceeded max iterations');\n }\n\n if (content.trim().length > 0) {\n this.push({\n type: TokenTypes.ERROR,\n value: content,\n });\n }\n }\n\n private handleChoice() {\n if (!this.isChoice()) {\n return;\n }\n\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n // skipping over the '+ '\n if (this.peek(1) === ' ') {\n this.step(CHOICE_PREFIX_LENGTH);\n this.pushAt(choiceToken('+ '), startLine, startColumn);\n } else {\n this.step(1);\n this.pushAt(choiceToken('+'), startLine, startColumn);\n }\n\n this.handleChoiceTextInline();\n }\n\n private handleChoiceTextInline() {\n const content = this.readLineUntilComment();\n\n if (content.length > 0) {\n // true stands for silent (inlined)\n this.push(choiceTextBoundToken(true));\n this.push(choiceTextToken(content));\n this.push(choiceTextBoundToken(true));\n }\n }\n\n private handleSection() {\n if (!this.isSection()) {\n return;\n }\n\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n // skipping over the '== '\n this.step(SECTION_PREFIX_LENGTH);\n this.pushAt(sectionToken(), startLine, startColumn);\n\n this.handleSectionName();\n }\n\n private handleSectionName() {\n let content = '';\n\n if (this.isWhitespace() || this.isNewLine()) {\n return;\n }\n\n while (this.curChar) {\n content += this.curChar;\n\n if (this.isWhitespace(1) || this.isNewLine(1)) {\n break;\n }\n\n this.step();\n }\n\n this.step();\n this.push(identifierToken(content));\n }\n\n private handleChoiceTag() {\n if (!this.isChoiceTag()) {\n return;\n }\n\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n // stepping over the first '@', so we can use getTagName fn.\n this.step();\n\n const tagName = `@${this.getTagName()}`;\n const value = this.getTagValue();\n\n this.pushAt(choiceTagToken(tagName), startLine, startColumn);\n\n if (value) {\n this.pushAt(tagValueToken(value), startLine, startColumn);\n }\n }\n\n private handleChoiceText() {\n if (this.isChoiceTextBound() && !this.isInChoiceText) {\n this.isInChoiceText = true;\n this.push(choiceTextBoundToken());\n this.step(3);\n\n this.handleChoiceTextExtend();\n } else if (this.isInChoiceText) {\n this.handleChoiceTextExtend();\n }\n }\n\n private handleChoiceTextExtend(isInlined = false) {\n let content = '';\n let iterations = 0;\n const maxIterations = 10000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && iterations < maxIterations) {\n if (this.isChoiceTextEnding()) {\n this.finalizeChoiceText(content, isInlined);\n return;\n }\n\n content = this.processChoiceTextContent(content);\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: handleChoiceTextExtend() exceeded max iterations');\n }\n\n this.finalizeChoiceText(content, isInlined);\n }\n\n private isChoiceTextEnding(): boolean {\n return this.isChoiceTextBound();\n }\n\n private processChoiceTextContent(content: string): string {\n if (this.isNewLine()) {\n return this.handleChoiceTextNewline(content);\n }\n\n if (this.isComment()) {\n return this.handleChoiceTextComment(content);\n }\n\n if (this.isMultiComment()) {\n return this.handleChoiceTextMultiComment(content);\n }\n\n content += this.curChar;\n this.step();\n return content;\n }\n\n private handleChoiceTextNewline(content: string): string {\n if (content.length > 0) {\n this.push(choiceTextToken(content));\n }\n this.push(newLineToken());\n this.step();\n return '';\n }\n\n private handleChoiceTextComment(content: string): string {\n if (content.length > 0) {\n this.push(choiceTextToken(content));\n }\n this.handleCommentContent();\n return '';\n }\n\n private handleChoiceTextMultiComment(content: string): string {\n if (content.length > 0) {\n this.push(choiceTextToken(content));\n }\n this.isInComment = true;\n this.step(2); // skip over /*\n this.push(multiCommentBeginToken());\n this.handleMultiCommentExtend();\n return '';\n }\n\n private finalizeChoiceText(content: string, isInlined: boolean): void {\n if (content.length > 0) {\n this.push(choiceTextToken(content));\n }\n\n this.isInChoiceText = false;\n this.step(CHOICE_TEXT_BOUND_LENGTH); // skip over ```\n this.push(choiceTextBoundToken(isInlined));\n }\n\n private handleTag() {\n if (!this.isTag()) {\n return;\n }\n\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n const tagName = this.getTagName();\n const value = this.getTagValue();\n\n this.pushAt(tagToken(tagName), startLine, startColumn);\n\n if (value) {\n this.pushAt(tagValueToken(value), startLine, startColumn);\n }\n }\n\n private handleCall() {\n if (!this.isCall()) {\n return;\n }\n\n // Skip over @call:\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n this.step(CALL_PREFIX_LENGTH);\n\n // Read function name\n let functionName = '';\n while (\n this.curChar &&\n this.curChar !== '(' &&\n this.curChar !== ' ' &&\n !this.isNewLine()\n ) {\n functionName += this.curChar;\n this.step();\n }\n\n // Handle escaped parentheses in function name\n if (this.curChar === '(' && this.isEscapedChar()) {\n // This is an escaped parenthesis, treat as part of function name\n functionName += this.curChar;\n this.step();\n\n // Continue reading until we find the real function call\n while (\n this.curChar &&\n this.curChar !== '(' &&\n this.curChar !== ' ' &&\n !this.isNewLine()\n ) {\n functionName += this.curChar;\n this.step();\n }\n }\n\n // Create call token first\n this.pushAt(callToken(functionName), startLine, startColumn);\n\n if (this.curChar === '(') {\n this.step(); // skip over (\n this.handleCallArguments();\n\n // Skip over closing )\n this.step();\n }\n }\n\n private handleCallArguments() {\n let depth = 1;\n let currentArg = '';\n let inQuotes = false;\n let argStartLine = this.curLine;\n let argStartColumn = this.curColumn;\n let iterations = 0;\n const maxIterations = 10000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && depth > 0 && iterations < maxIterations) {\n if (this.curChar === '\"' && !this.isEscapedChar()) {\n inQuotes = !inQuotes;\n }\n\n if (!inQuotes) {\n if (this.curChar === '(' && !this.isEscapedChar()) {\n depth++;\n } else if (this.curChar === ')' && !this.isEscapedChar()) {\n depth--;\n if (depth === 0) {\n break;\n }\n } else if (this.curChar === ',' && depth === 1) {\n if (currentArg.trim().length > 0) {\n this.pushAt(\n callArgumentToken(currentArg.trim()),\n argStartLine,\n argStartColumn\n );\n }\n currentArg = '';\n this.step();\n argStartLine = this.curLine;\n argStartColumn = this.curColumn;\n iterations++;\n continue;\n }\n }\n\n currentArg += this.curChar;\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: handleCallArguments() exceeded max iterations');\n }\n\n if (currentArg.trim().length > 0) {\n this.pushAt(\n callArgumentToken(currentArg.trim()),\n argStartLine,\n argStartColumn\n );\n }\n }\n\n private getTagName() {\n let tagName = '';\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && iterations < maxIterations) {\n tagName += this.curChar;\n\n if (this.isWhitespace(1) || this.isNewLine(1)) {\n break;\n }\n\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: getTagName() exceeded max iterations');\n }\n\n this.step();\n return tagName;\n }\n\n private getTagValue() {\n let value = '';\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n if (this.getTokenType(0)) {\n return value;\n }\n\n while (this.curChar && iterations < maxIterations) {\n if (this.getTokenType(1) || this.isEOF(1)) {\n break;\n }\n\n this.step();\n value += this.curChar;\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: getTagValue() exceeded max iterations');\n }\n\n this.step();\n return value;\n }\n\n private handleString() {\n if (this.isReplicaBegin()) {\n if (this.isExtendingStr) {\n this.handleEndReplica();\n }\n\n this.handleNewReplica();\n } else if (this.isExtendingStr) {\n this.handleExtendReplica();\n } else {\n // Check for inline calls in text\n this.handleInlineCall();\n }\n }\n\n private handleInlineCall(): string {\n if (!this.isInlineCall()) {\n return '';\n }\n let result = '{call:';\n this.step(INLINE_CALL_PREFIX_LENGTH);\n // Read function name\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (\n this.curChar &&\n this.curChar !== '(' &&\n this.curChar !== '}' &&\n iterations < maxIterations\n ) {\n result += this.curChar;\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn(\n 'Warning: handleInlineCall() function name reading exceeded max iterations'\n );\n }\n\n if (this.curChar === '(') {\n result += this.curChar;\n this.step();\n let depth = 1;\n let parenIterations = 0;\n const maxParenIterations = 10000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (\n this.curChar &&\n depth > 0 &&\n parenIterations < maxParenIterations\n ) {\n result += this.curChar;\n if (this.curChar === '(' && !this.isEscapedChar()) {\n depth++;\n } else if ((this.curChar as string) === ')' && !this.isEscapedChar()) {\n depth--;\n }\n this.step();\n parenIterations++;\n }\n\n if (parenIterations >= maxParenIterations) {\n console.warn(\n 'Warning: handleInlineCall() parentheses processing exceeded max iterations'\n );\n }\n }\n if (this.curChar === '}') {\n result += '}';\n this.step();\n }\n return result;\n }\n\n private isInlineCall(offset = 0) {\n return (\n this.isNotEscapingChar('{', offset) &&\n this.peek(offset + 1) === 'c' &&\n this.peek(offset + 2) === 'a' &&\n this.peek(offset + 3) === 'l' &&\n this.peek(offset + 4) === 'l' &&\n this.peek(offset + 5) === ':'\n );\n }\n\n private handleNewReplica() {\n const startLine = this.curLine;\n const startColumn = this.curColumn;\n this.step(REPLICA_PREFIX_LENGTH);\n this.isExtendingStr = true;\n this.pushAt(replicaBeginToken(), startLine, startColumn);\n\n this.handleExtendReplica();\n }\n\n private handleExtendReplica() {\n let content = '';\n\n if (this.shouldEndReplica()) {\n this.handleEndReplica();\n return;\n }\n\n content = this.handleInlineCallInReplica(content);\n\n if (this.getTokenType(0)) {\n return;\n }\n\n content = this.processReplicaContent(content);\n\n this.finalizeReplicaContent(content);\n }\n\n private shouldEndReplica(): boolean {\n return (\n this.isReplicaBegin() ||\n this.isTag() ||\n this.isChoiceTag() ||\n this.isGoto() ||\n this.isChoice() ||\n this.isSection() ||\n this.isEOF()\n );\n }\n\n private handleInlineCallInReplica(content: string): string {\n if (this.isInlineCall()) {\n this.addStringTokenIfNotEmpty(content);\n return this.handleInlineCall();\n }\n return content;\n }\n\n private addStringTokenIfNotEmpty(content: string): void {\n if (content.trim().length > 0) {\n this.pushAt(\n stringToken(content),\n this.stringStartLine,\n this.stringStartColumn\n );\n this.lastStringTokenIndex = this.tokens.length - 1;\n }\n }\n\n private processReplicaContent(content: string): string {\n let isReplicaEnding = false;\n let iterations = 0;\n const maxIterations = 10000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && iterations < maxIterations) {\n if (this.isInlineCall()) {\n this.addStringTokenIfNotEmpty(content);\n content = this.handleInlineCall();\n iterations++;\n continue;\n }\n\n if (content.length === 0) {\n this.stringStartLine = this.curLine;\n this.stringStartColumn = this.curColumn;\n }\n content += this.curChar;\n\n if (isReplicaEnding) {\n isReplicaEnding = true;\n break;\n }\n\n if (this.getTokenType(1)) {\n break;\n }\n\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: processReplicaContent() exceeded max iterations');\n }\n\n this.step();\n return content;\n }\n\n private isReplicaEnding(): boolean {\n return (\n this.isTag(1) ||\n this.isChoiceTag(1) ||\n this.isReplicaBegin(1) ||\n this.isEOF(1)\n );\n }\n\n private finalizeReplicaContent(content: string): void {\n if (content.trim().length > 0) {\n this.pushAt(\n stringToken(content),\n this.stringStartLine,\n this.stringStartColumn\n );\n this.lastStringTokenIndex = this.tokens.length - 1;\n }\n }\n\n private handleEndReplica() {\n this.isExtendingStr = false;\n this.insertTokenAt(this.lastStringTokenIndex + 1, replicaEndToken());\n }\n\n private handleIndent() {\n if (this.isInComment) {\n return;\n }\n\n // Only process indentation if we're at the beginning of a line\n if (!this.isFirstOnLine()) {\n return;\n }\n\n let spaces = 0;\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && this.isWhitespace() && iterations < maxIterations) {\n if (this.isNewLine()) {\n break;\n }\n\n if (this.curChar === ' ') {\n spaces++;\n }\n\n if (this.curChar === '\\t') {\n spaces += 2;\n }\n\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: handleIndent() exceeded max iterations');\n }\n\n let indentDiff = Math.floor((spaces - this.prevIndent) / INDENT_WIDTH);\n\n // End current replica on indent change\n if (indentDiff !== 0 && this.isExtendingStr) {\n this.handleEndReplica();\n }\n\n while (indentDiff !== 0) {\n if (indentDiff > 0) {\n this.push(indentToken());\n indentDiff--;\n } else {\n this.push(dedentToken());\n indentDiff++;\n }\n }\n\n this.prevIndent = spaces;\n }\n\n private handleComment() {\n if (this.isInComment) {\n this.handleMultiCommentExtend();\n return;\n }\n\n // new multiline comment\n if (this.isMultiComment()) {\n this.isInComment = true;\n this.step(MULTI_COMMENT_START_LENGTH); // skip over /*\n this.push(multiCommentBeginToken());\n this.handleMultiCommentExtend();\n return;\n }\n\n if (this.isComment()) {\n this.handleCommentContent();\n }\n }\n\n private handleCommentContent() {\n // Include the # symbol in the content\n let content = '#';\n this.step(); // skip over #\n\n while (this.curChar && !this.isEndOfLine()) {\n content += this.curChar;\n this.step();\n }\n\n if (content.length > 1) {\n // More than just '#'\n this.push(commentToken(content));\n }\n }\n\n private handleMultiCommentExtend() {\n let content = '';\n let iterations = 0;\n const maxIterations = 10000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n // check cur char\n if (this.isMultiCommentEnd()) {\n this.isInComment = false;\n this.push(multiCommentEndToken());\n this.step(MULTI_COMMENT_END_LENGTH); // skip over */\n return;\n }\n\n if (this.isNewLine()) {\n return;\n }\n\n // check next char\n while (this.curChar && iterations < maxIterations) {\n content += this.curChar;\n\n if (this.isEndOfLine(1) || this.isMultiCommentEnd(1)) {\n break;\n }\n\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn(\n 'Warning: handleMultiCommentExtend() exceeded max iterations'\n );\n }\n\n // adding comment content\n this.push(commentContentToken(content));\n\n if (this.isMultiCommentEnd(1)) {\n this.isInComment = false;\n this.push(multiCommentEndToken());\n this.step(2);\n }\n\n this.step();\n }\n\n private handleNewLine() {\n if (this.curChar === '\\n') {\n this.push(newLineToken());\n this.step();\n }\n }\n\n private getTokenType(offset = 0): TokenType | undefined {\n type TokenInfo = {\n type: TokenType;\n fn: (offset: number) => boolean;\n };\n\n const tokens: TokenInfo[] = [\n { type: TokenTypes.NEWLINE, fn: this.isNewLine },\n { type: TokenTypes.CHOICE_TAG, fn: this.isChoiceTag },\n { type: TokenTypes.TAG, fn: this.isTag },\n { type: TokenTypes.COMMENT, fn: this.isComment },\n { type: TokenTypes.COMMENT_MULTILINE_BEGIN, fn: this.isMultiComment },\n { type: TokenTypes.GOTO, fn: this.isGoto },\n { type: TokenTypes.SECTION, fn: this.isSection },\n { type: TokenTypes.CHOICE, fn: this.isChoice },\n { type: TokenTypes.STRING, fn: this.isReplicaBegin },\n { type: TokenTypes.CHOICE_TEXT_BOUND, fn: this.isChoiceTextBound },\n { type: TokenTypes.CALL, fn: this.isCall },\n ];\n\n return tokens.find((info) => {\n return info.fn.call(this, offset);\n })?.type;\n }\n\n private isGoto(offset = 0) {\n if (\n !this.isNotEscapingChar('=', offset) &&\n !this.isNotEscapingChar('-', offset)\n ) {\n return false;\n }\n\n return this.peek(offset + 1) === '>' && this.peek(offset + 2) === ' ';\n }\n\n private isSection(offset = 0) {\n return (\n this.isNotEscapingChar('=', offset) &&\n this.peek(offset + 1) === '=' &&\n this.peek(offset + 2) === ' '\n );\n }\n\n private isChoice(offset = 0) {\n if (!this.isFirstOnLine(offset)) {\n return false;\n }\n\n return this.isNotEscapingChar('+', offset);\n }\n\n private isChoiceTextBound(offset = 0) {\n return (\n this.isNotEscapingChar('`', offset) &&\n this.peek(offset + 1) === '`' &&\n this.peek(offset + 2) === '`'\n );\n }\n\n private isReplicaBegin(offset = 0) {\n return this.isNotEscapingChar('\"', offset) && this.peek(offset + 1) === ' ';\n // return this.peek(offset) === '\"' && this.peek(offset - 1) !== '\\\\';\n }\n\n private isTag(offset = 0) {\n return this.isNotEscapingChar('@', offset);\n }\n\n private isChoiceTag(offset = 0) {\n return this.isNotEscapingChar('@', offset) && this.peek(1) === '@';\n }\n\n private isComment(offset = 0) {\n return this.peek(offset) === '#' && this.peek(offset - 1) !== '\\\\';\n }\n\n private isMultiComment(offset = 0) {\n return this.isNotEscapingChar('/', offset) && this.peek(offset + 1) === '*';\n }\n\n private isMultiCommentEnd(offset = 0) {\n return this.isNotEscapingChar('*', offset) && this.peek(offset + 1) === '/';\n }\n\n private isNewLine(offset = 0) {\n return this.peek(offset) === '\\n';\n }\n\n private isEOF(offset = 0) {\n return this.peek(offset) === undefined;\n }\n\n private isFirstOnLine(offset = 0) {\n let prevPos = offset - 1;\n\n while (prevPos >= 0) {\n if (this.isEOF(prevPos) || this.isNewLine(prevPos)) {\n return true;\n }\n\n if (!this.isWhitespace(prevPos)) {\n return false;\n }\n\n prevPos--;\n }\n\n return true; // \u0414\u043E\u0441\u0442\u0438\u0433\u043B\u0438 \u043D\u0430\u0447\u0430\u043B\u0430 \u0444\u0430\u0439\u043B\u0430\n }\n\n private readLine() {\n let content = '';\n\n while (this.curChar && !this.isEndOfLine()) {\n content += this.curChar;\n this.step();\n }\n\n return content;\n }\n\n private readLineUntilComment() {\n let content = '';\n let iterations = 0;\n const maxIterations = 1000; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n\n while (this.curChar && !this.isEndOfLine() && iterations < maxIterations) {\n if (this.isComment() || this.isMultiComment()) {\n break;\n }\n\n content += this.curChar;\n this.step();\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: readLineUntilComment() exceeded max iterations');\n }\n\n return content;\n }\n\n private isNotEscapingChar(char: string, offset = 0) {\n return this.peek(offset) === char && this.peek(offset - 1) !== '\\\\';\n }\n\n private isEndOfLine(offset = 0): boolean {\n return this.isNewLine(offset) || this.isEOF(offset);\n }\n\n private isWhitespace(offset = 0): boolean {\n const char = this.peek(offset);\n return char === ' ' || char === '\\t';\n }\n\n private isEscapedChar(offset = 0): boolean {\n return this.peek(offset - 1) === '\\\\';\n }\n\n private peek(pos = 0) {\n return this.source[this.position + pos];\n }\n\n private isCall(offset = 0) {\n return (\n this.isNotEscapingChar('@', offset) &&\n this.peek(offset + 1) === 'c' &&\n this.peek(offset + 2) === 'a' &&\n this.peek(offset + 3) === 'l' &&\n this.peek(offset + 4) === 'l' &&\n this.peek(offset + 5) === ':'\n );\n }\n}\n", "// Core types and interfaces\nexport type {\n AstCall,\n AstChoice,\n AstGoto,\n AstInlineCallSegment,\n AstProgram,\n AstReplica,\n AstSection,\n AstStatement,\n AstTag,\n AstTextSegment,\n SourcePosition,\n} from './ast';\n// Lexer exports\n// biome-ignore lint/performance/noBarrelFile: Core package barrel file for external API\nexport { Lexer } from './lexer';\n\n// Parser exports\nexport {\n ParseError,\n type ParseResult,\n type ParserIssue,\n parseAll,\n parseFromSource,\n parseProgramFromTokens,\n} from './parser';\nexport type {\n Token,\n TokenType,\n TokenTypes,\n} from './token';\n\n// Token factory exports\nexport {\n callArgumentToken,\n callToken,\n choiceTagToken,\n choiceTextBoundToken,\n choiceTextToken,\n choiceToken,\n commentContentToken,\n commentToken,\n dedentToken,\n eofToken,\n gotoToken,\n identifierToken,\n indentToken,\n multiCommentBeginToken,\n multiCommentEndToken,\n newLineToken,\n replicaBeginToken,\n replicaEndToken,\n sectionToken,\n stringToken,\n tagToken,\n tagValueToken,\n} from './tokenFactory';\n// Utility exports\nexport { printToken } from './utils/printToken';\n// Validator exports\nexport {\n type ValidationIssue,\n validateProgram,\n validateTokens,\n} from './validator';\n", "import type {\n AstInlineCallSegment,\n AstProgram,\n AstStatement,\n AstTag,\n AstTextSegment,\n SourcePosition,\n} from './ast';\nimport { type Token, TokenTypes } from './token';\n\nexport class ParseError extends Error {\n public constructor(message: string) {\n super(message);\n this.name = 'ParseError';\n }\n}\n\nexport type ParserIssue = {\n kind: 'Error' | 'Warning';\n message: string;\n position?: SourcePosition;\n endPosition?: SourcePosition;\n};\n\nlet lastParserIssues: ParserIssue[] = [];\nexport function getParserIssues(): ParserIssue[] {\n return lastParserIssues;\n}\n\n// Internal helper: push issue to provided sink or fallback to legacy global buffer\nfunction addIssue(sink: ParserIssue[] | undefined, issue: ParserIssue) {\n if (sink) {\n sink.push(issue);\n return;\n }\n lastParserIssues.push(issue);\n}\n\nexport type ParseResult = { program: AstProgram; issues: ParserIssue[] };\n\nexport function parseProgramFromTokens(\n tokens: Token<unknown>[],\n issues?: ParserIssue[]\n): ParseResult {\n const program = buildAstFromTokens(tokens, issues);\n return { program, issues: issues ?? getParserIssues() };\n}\n\n// Convenience: full parse from raw source using the project's Lexer\nexport function parseFromSource(source: string): ParseResult {\n // Lazy import to avoid circular deps at module load time\n const { Lexer } = require('./lexer');\n const lexer = new Lexer(source);\n lexer.process();\n const tokens = lexer.getTokens();\n return parseProgramFromTokens(tokens);\n}\n\nexport function parseAll(source: string): {\n tokens: Token<unknown>[];\n program: AstProgram;\n issues: ParserIssue[];\n} {\n const { Lexer } = require('./lexer');\n const lexer = new Lexer(source);\n lexer.process();\n const tokens = lexer.getTokens();\n const issues: ParserIssue[] = [];\n const program = buildAstFromTokens(tokens, issues);\n return { tokens, program, issues };\n}\n\n// Constants to avoid magic numbers\n// Number of tokens that form a section header: [SECTION, IDENTIFIER]\nconst SECTION_HEADER_TOKEN_COUNT = 2;\n// Number of tokens consumed by a goto statement: [GOTO, IDENTIFIER]\nconst GOTO_TOKENS_CONSUMED = 2;\n\n// Parser entrypoint: builds AST from tokens. Will be expanded in next steps.\n// Builds a high-level AST with sections and flat statements.\n// At this stage, bodies contain only simple statements (Goto, Call, Replica).\nexport const buildAstFromTokens = (\n allTokens: Token<unknown>[],\n issues?: ParserIssue[]\n): AstProgram => {\n // start new parser issues buffer\n lastParserIssues = [];\n const sections: AstProgram['sections'] = [];\n\n // Find explicit sections by positions of SECTION tokens\n const sectionIndices = findSectionIndices(allTokens);\n\n if (sectionIndices.length === 0) {\n // No explicit sections \u2192 implicit main with parsed simple statements from whole file\n const body = parseSimpleStatements(allTokens, {\n collectIssues: true,\n issues,\n });\n sections.push({\n name: 'main',\n body,\n position: getTokenPosition(allTokens[0]),\n endPosition: getTokenEndPosition(allTokens[allTokens.length - 1]),\n });\n return { sections };\n }\n\n // Prelude before the first SECTION becomes implicit main (if any meaningful tokens exist)\n const prelude = allTokens.slice(0, sectionIndices[0]);\n\n const hasPreludeContent = prelude.some(\n (t) =>\n t.type !== TokenTypes.NEWLINE &&\n t.type !== TokenTypes.COMMENT &&\n t.type !== TokenTypes.COMMENT_CONTENT\n );\n\n if (hasPreludeContent) {\n const body = parseSimpleStatements(prelude, {\n collectIssues: true,\n issues,\n });\n const preludeStartTok = prelude[0] ?? allTokens[0];\n const preludeEndTok = allTokens[Math.max(0, sectionIndices[0] - 1)];\n sections.push({\n name: 'main',\n body,\n position: getTokenPosition(preludeStartTok),\n endPosition: getTokenEndPosition(preludeEndTok),\n });\n }\n\n // Collect explicit sections names (identifier after SECTION)\n for (const idx of sectionIndices) {\n const sectionTok = allTokens[idx];\n const nextIdx =\n sectionIndices.find((v: number) => v > idx) ?? allTokens.length;\n const nameTok = allTokens[idx + 1];\n\n if (!nameTok || nameTok.type !== TokenTypes.IDENTIFIER) {\n addIssue(issues, {\n kind: 'Error',\n message: 'Expected section name (IDENTIFIER) after SECTION token',\n position: getTokenPosition(sectionTok),\n endPosition: getTokenEndPosition(sectionTok),\n });\n // skip this malformed section body and continue with next\n continue;\n }\n\n const name = String(nameTok.value ?? '');\n const sectionWindow = allTokens.slice(\n idx + SECTION_HEADER_TOKEN_COUNT,\n nextIdx\n );\n const body = parseSimpleStatements(sectionWindow, {\n collectIssues: true,\n issues,\n });\n sections.push({\n name,\n body,\n position: getTokenPosition(sectionTok),\n endPosition: getTokenEndPosition(allTokens[nextIdx - 1]),\n });\n }\n\n return { sections };\n};\n\n// Small step forward: parse only simple statements (Goto, Call) from a flat token window\n// Parse a flat list of simple statements from a token window.\n// This pass ignores indentation, tags and choices; it's meant as an incremental step.\n// Parses a flat sequence of statements from a token window.\n// Strategy:\n// - Accumulate leading @tags and @@choice_tags in buffers (pendingTags / pendingChoiceTags)\n// - Skip non-semantic tokens (newline/comments) between tags and the statement\n// - Attach the accumulated tags to the next parsed statement, then reset buffers\ntype ParseOptions = { collectIssues?: boolean; issues?: ParserIssue[] };\n\nexport const parseSimpleStatements = (\n tokens: Token<unknown>[],\n options: ParseOptions = { collectIssues: true }\n): AstStatement[] => {\n const result: AstStatement[] = [];\n let currentIndex = 0;\n let iterations = 0;\n const maxIterations = tokens.length * 2; // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0431\u0435\u0441\u043A\u043E\u043D\u0435\u0447\u043D\u043E\u0433\u043E \u0446\u0438\u043A\u043B\u0430\n // Regular tags meant for the very next statement\n let pendingTags: AstTag[] = [];\n // Choice-only tags (begin with @@) meant for the next Choice node\n let pendingChoiceTags: AstTag[] = [];\n\n while (currentIndex < tokens.length && iterations < maxIterations) {\n // Current token under consideration\n const currentToken = tokens[currentIndex];\n\n // Collect leading tags for the next statement\n if (currentToken.type === TokenTypes.TAG) {\n const { tags, nextStartIndex } = collectLeadingTags(\n tokens.slice(currentIndex)\n );\n pendingTags.push(...tags);\n currentIndex += nextStartIndex;\n // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0437\u0430\u0441\u0442\u0440\u0435\u0432\u0430\u043D\u0438\u044F - \u0435\u0441\u043B\u0438 nextStartIndex \u0440\u0430\u0432\u0435\u043D 0\n if (nextStartIndex === 0) {\n currentIndex++;\n console.warn(\n 'Warning: collectLeadingTags returned nextStartIndex=0, forcing increment'\n );\n }\n iterations++;\n continue;\n }\n\n // Collect leading choice tags (start with @@). They are attached only to Choice\n if (currentToken.type === TokenTypes.CHOICE_TAG) {\n const { tags, nextStartIndex } = collectLeadingChoiceTags(\n tokens.slice(currentIndex)\n );\n pendingChoiceTags.push(...tags);\n currentIndex += nextStartIndex;\n // \u0417\u0430\u0449\u0438\u0442\u0430 \u043E\u0442 \u0437\u0430\u0441\u0442\u0440\u0435\u0432\u0430\u043D\u0438\u044F - \u0435\u0441\u043B\u0438 nextStartIndex \u0440\u0430\u0432\u0435\u043D 0\n if (nextStartIndex === 0) {\n currentIndex++;\n console.warn(\n 'Warning: collectLeadingChoiceTags returned nextStartIndex=0, forcing increment'\n );\n }\n iterations++;\n continue;\n }\n\n // Skip non-semantic tokens between tags and statements\n if (\n currentToken.type === TokenTypes.NEWLINE ||\n currentToken.type === TokenTypes.COMMENT ||\n currentToken.type === TokenTypes.COMMENT_CONTENT\n ) {\n currentIndex++;\n iterations++;\n continue;\n }\n\n // Parse choice statements (text + optional body). Consumes choice-related tokens\n if (currentToken.type === TokenTypes.CHOICE) {\n const { node, nextIndex } = parseChoiceAt(\n tokens,\n currentIndex,\n pendingTags,\n pendingChoiceTags,\n options.issues\n );\n result.push(node);\n pendingTags = [];\n pendingChoiceTags = [];\n currentIndex = nextIndex;\n iterations++;\n continue;\n }\n\n // Parse goto statements: GOTO IDENTIFIER\n if (currentToken.type === TokenTypes.GOTO) {\n const nextToken = tokens[currentIndex + 1];\n if (\n !nextToken ||\n nextToken.type !== TokenTypes.IDENTIFIER ||\n !nextToken.value\n ) {\n if (options.collectIssues)\n addIssue(options.issues, {\n kind: 'Error',\n message: 'Goto must be followed by IDENTIFIER',\n position: getTokenPosition(currentToken),\n endPosition: getTokenEndPosition(currentToken),\n });\n // sync: skip to end of line/body\n currentIndex++;\n iterations++;\n continue;\n }\n result.push({\n kind: 'Goto',\n target: String(nextToken.value ?? ''),\n tags: pendingTags.length ? pendingTags : undefined,\n position: getTokenPosition(currentToken),\n endPosition: getTokenEndPosition(nextToken),\n });\n pendingTags = [];\n currentIndex += GOTO_TOKENS_CONSUMED;\n iterations++;\n continue;\n }\n\n // Parse function calls: CALL (CALL_ARGUMENT)*\n if (currentToken.type === TokenTypes.CALL) {\n const functionName = String(currentToken.value ?? '');\n const callArguments: string[] = [];\n let argumentIndex = currentIndex + 1;\n\n while (\n argumentIndex < tokens.length &&\n tokens[argumentIndex].type === TokenTypes.CALL_ARGUMENT\n ) {\n callArguments.push(String(tokens[argumentIndex].value ?? ''));\n argumentIndex++;\n }\n\n // If we see a NEWLINE right after CALL (no args and likely broken), issue a warning and continue\n if (\n callArguments.length === 0 &&\n tokens[argumentIndex]?.type === TokenTypes.NEWLINE\n ) {\n if (options.collectIssues)\n addIssue(options.issues, {\n kind: 'Warning',\n message: `Empty or malformed call: ${functionName}()`,\n position: getTokenPosition(currentToken),\n endPosition: getTokenEndPosition(\n tokens[Math.max(currentIndex, argumentIndex)]\n ),\n });\n }\n\n result.push({\n kind: 'Call',\n name: functionName,\n args: callArguments,\n tags: pendingTags.length ? pendingTags : undefined,\n position: getTokenPosition(currentToken),\n endPosition: getTokenEndPosition(\n tokens[Math.max(currentIndex, argumentIndex - 1)]\n ),\n });\n pendingTags = [];\n currentIndex = argumentIndex;\n iterations++;\n continue;\n }\n\n // Parse replica blocks: REPLICA_BEGIN STRING* REPLICA_END\n if (currentToken.type === TokenTypes.REPLICA_BEGIN) {\n let scanIndex = currentIndex + 1;\n const startPos = getTokenPosition(currentToken);\n const stringTokens: Token<unknown>[] = [];\n\n while (\n scanIndex < tokens.length &&\n tokens[scanIndex].type !== TokenTypes.REPLICA_END\n ) {\n const t = tokens[scanIndex];\n if (t.type === TokenTypes.STRING) {\n stringTokens.push(t);\n }\n scanIndex++;\n }\n\n const fullText = stringTokens.map((t) => String(t.value ?? '')).join('');\n const pieces = buildTextPieces(stringTokens);\n const segments = splitTextIntoSegmentsWithPositions(\n fullText,\n pieces,\n options.issues\n );\n\n const endTok = tokens[Math.min(scanIndex, tokens.length - 1)];\n const replicaNode = {\n kind: 'Replica',\n text: segments\n .filter((s) => s.kind === 'Text')\n .map((s) => (s as AstTextSegment).text)\n .join(''),\n segments: segments.length > 0 ? segments : undefined,\n tags: pendingTags.length ? pendingTags : undefined,\n position: startPos,\n endPosition: getTokenEndPosition(endTok),\n } as AstStatement;\n\n result.push(replicaNode);\n pendingTags = [];\n currentIndex =\n tokens[scanIndex]?.type === TokenTypes.REPLICA_END\n ? scanIndex + 1\n : scanIndex;\n iterations++;\n continue;\n }\n\n currentIndex++;\n iterations++;\n }\n\n if (iterations >= maxIterations) {\n console.warn('Warning: parseSimpleStatements() exceeded max iterations');\n }\n\n return result;\n};\n\n// Collect leading @tag entries at the very start of a token window\n// Stops on the first non-tag token; returns collected tags and index to continue from\n// Collect a run of leading @tags at the very start of a token window.\n// Each tag can be followed by an optional TAG_VALUE token which becomes Tag.value.\nexport const collectLeadingTags = (\n tokens: Token<unknown>[]\n): { tags: AstTag[]; nextStartIndex: number } => {\n const collected: AstTag[] = [];\n let index = 0;\n\n while (index < tokens.length) {\n const t = tokens[index];\n if (t.type !== TokenTypes.TAG) break;\n\n const name = String(t.value ?? '');\n