feat: add format spec parser

This commit is contained in:
2026-02-07 19:14:01 +01:00
parent 9af843e802
commit 2784518887
9 changed files with 205 additions and 29 deletions

View File

@@ -6,4 +6,4 @@ print(f"{b:_}")
let pts = 19 let pts = 19
let total = 22 let total = 22
print(f"Correct answers: {points/total:.2%}") print(f"Correct answers: {pts/total:.2%}")

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, TypeVar, Generic from typing import Any, TypeVar, Generic, Optional
from src.core.format_spec.spec import FormatSpec
from src.token.token import Token from src.token.token import Token
@@ -151,6 +152,7 @@ class FStringExpr(Expr):
class FStringEmbedExpr(Expr): class FStringEmbedExpr(Expr):
start: Token start: Token
expression: Expr expression: Expr
spec: Optional[FormatSpec]
end: Token end: Token
def accept(self, visitor: Expr.Visitor[T]) -> T: def accept(self, visitor: Expr.Visitor[T]) -> T:

View File

@@ -103,8 +103,8 @@ class FormatSpecLexer:
self.add_token(TokenType.T_FIX) self.add_token(TokenType.T_FIX)
case "%": case "%":
self.add_token(TokenType.T_PCT) self.add_token(TokenType.T_PCT)
case "." if self.peek().isdigit(): case ".":
self.scan_number(True) self.add_token(TokenType.DOT)
case _: case _:
if char.isdigit(): if char.isdigit():
self.scan_number() self.scan_number()
@@ -112,18 +112,9 @@ class FormatSpecLexer:
self.error("Unexpected character") self.error("Unexpected character")
return None return None
def scan_number(self, decimal_only: bool = False): def scan_number(self):
while self.peek().isdigit(): while self.peek().isdigit():
self.advance() self.advance()
if not decimal_only: value: float = float(self.source[self.start:self.idx])
if self.peek() == "." and self.peek_next().isdigit():
self.advance()
while self.peek().isdigit():
self.advance()
value_str: str = self.source[self.start:self.idx]
if decimal_only:
value_str = f"0{value_str}"
value: float = float(value_str)
self.add_token(TokenType.NUMBER, value) self.add_token(TokenType.NUMBER, value)

View File

@@ -0,0 +1,119 @@
from typing import Optional
from src.core.format_spec.spec import FormatSpec, FormatSpecOptions, FormatSpecNumber, FormatSpecIntegral, \
FormatSpecDecimal
from src.core.format_spec.token import Token, TokenType
from src.parser.error import ParsingError
from src.pebble import Pebble
class FormatSpecParser:
TYPES: set[TokenType] = {
TokenType.T_STR,
TokenType.T_BIN,
TokenType.T_DEC,
TokenType.T_OCT,
TokenType.T_HEX,
TokenType.T_SCI,
TokenType.T_FIX,
TokenType.T_PCT,
}
def __init__(self, tokens: list[Token]):
self.tokens: list[Token] = tokens
self.current: int = 0
self.length: int = len(self.tokens)
@staticmethod
def error(token: Token, msg: str):
Pebble.token_error(token, msg)
return ParsingError()
def parse(self) -> FormatSpec:
return self.spec()
def is_at_end(self) -> bool:
return self.peek().type == TokenType.EOF
def peek(self) -> Token:
return self.tokens[self.current]
def previous(self) -> Token:
return self.tokens[self.current - 1]
def check(self, token_type: TokenType) -> bool:
if self.is_at_end():
return False
return self.peek().type == token_type
def advance(self):
token: Token = self.peek()
self.current += 1
return token
def match(self, *types: TokenType) -> bool:
for token_type in types:
if self.check(token_type):
self.advance()
return True
return False
def consume(self, token_type: TokenType, error_msg: str) -> Token:
if self.check(token_type):
return self.advance()
raise self.error(self.peek(), error_msg)
# Parsing
def spec(self) -> FormatSpec:
options: FormatSpecOptions = self.options()
number: FormatSpecNumber = self.number()
type: Optional[Token] = self.type()
return FormatSpec(
options=options,
number=number,
type=type
)
def options(self) -> FormatSpecOptions:
sign: Optional[Token] = None
if self.match(TokenType.PLUS, TokenType.MINUS, TokenType.SPACE):
sign = self.previous()
return FormatSpecOptions(
sign=sign
)
def number(self) -> FormatSpecNumber:
integral: FormatSpecIntegral = self.integral()
decimal: FormatSpecDecimal = self.decimal()
return FormatSpecNumber(integral=integral, decimal=decimal)
def integral(self) -> FormatSpecIntegral:
width: Optional[int] = None
grouping: Optional[Token] = None
if self.match(TokenType.NUMBER):
width = self.previous().value
if self.match(TokenType.COMMA, TokenType.UNDERSCORE):
grouping = self.previous().value
return FormatSpecIntegral(
width=width,
grouping=grouping
)
def decimal(self) -> FormatSpecDecimal:
precision: Optional[int] = None
grouping: Optional[Token] = None
if self.match(TokenType.DOT):
if self.match(TokenType.NUMBER):
precision = self.previous().value
if self.match(TokenType.COMMA, TokenType.UNDERSCORE):
grouping = self.previous().value
return FormatSpecDecimal(
precision=precision,
grouping=grouping
)
def type(self) -> Optional[Token]:
if self.match(*self.TYPES):
return self.previous()
return None

View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass
from typing import Optional
from src.core.format_spec.token import Token
@dataclass(frozen=True)
class FormatSpecOptions:
sign: Optional[Token]
@dataclass(frozen=True)
class FormatSpecIntegral:
width: Optional[int]
grouping: Optional[Token]
@dataclass(frozen=True)
class FormatSpecDecimal:
precision: Optional[int]
grouping: Optional[Token]
@dataclass(frozen=True)
class FormatSpecNumber:
integral: FormatSpecIntegral
decimal: FormatSpecDecimal
@dataclass(frozen=True)
class FormatSpec:
options: FormatSpecOptions
number: FormatSpecNumber
type: Optional[Token]

View File

@@ -0,0 +1,30 @@
from typing import Any
from src.core.format_spec.spec import FormatSpec
from src.core.format_spec.token import TokenType, Token
from src.interpreter.error import PebbleRuntimeError
class StringFormatter:
@staticmethod
def stringify(obj: Any):
if obj is None:
return "null"
if obj is True:
return "true"
if obj is False:
return "false"
if isinstance(obj, (int, float)):
if obj.is_integer():
obj = int(obj)
return str(obj)
return obj
@staticmethod
def check_type(token: Token, obj: Any, expected_type: type | tuple[type, ...]):
if not isinstance(obj, expected_type):
raise PebbleRuntimeError(token, f"Invalid value type. Expected {expected_type}, got {type(obj)}")
def format(self, obj: Any, spec: FormatSpec) -> str:
# TODO
return str(obj)

View File

@@ -32,6 +32,7 @@ class TokenType(Enum):
# Misc # Misc
NUMBER = auto() NUMBER = auto()
DOT = auto()
EOF = auto() EOF = auto()

View File

@@ -6,6 +6,7 @@ from src.ast.stmt import Stmt, ExpressionStmt, LetStmt, BlockStmt, IfStmt, While
ReturnStmt, BreakStmt, ContinueStmt, ClassStmt ReturnStmt, BreakStmt, ContinueStmt, ClassStmt
from src.consts import CONSTRUCTOR_NAME from src.consts import CONSTRUCTOR_NAME
from src.core.callable import PebbleCallable from src.core.callable import PebbleCallable
from src.core.format_spec.string_formatter import StringFormatter
from src.core.function import PebbleFunction from src.core.function import PebbleFunction
from src.core.instance import PebbleInstance from src.core.instance import PebbleInstance
from src.core.klass import PebbleClass from src.core.klass import PebbleClass
@@ -186,7 +187,10 @@ class Interpreter(Expr.Visitor[Any], Stmt.Visitor[None]):
]) ])
def visit_fstring_embed_expr(self, expr: FStringEmbedExpr) -> Any: def visit_fstring_embed_expr(self, expr: FStringEmbedExpr) -> Any:
return self.stringify(self.evaluate(expr.expression)) value: Any = self.evaluate(expr.expression)
if expr.spec is None:
return self.stringify(value)
return StringFormatter().format(value, expr.spec)
def visit_variable_expr(self, expr: VariableExpr) -> Any: def visit_variable_expr(self, expr: VariableExpr) -> Any:
return self.look_up_variable(expr.name, expr) return self.look_up_variable(expr.name, expr)
@@ -343,14 +347,4 @@ class Interpreter(Expr.Visitor[Any], Stmt.Visitor[None]):
@staticmethod @staticmethod
def stringify(obj: Any) -> str: def stringify(obj: Any) -> str:
if obj is None: return StringFormatter.stringify(obj)
return "null"
if obj is True:
return "true"
if obj is False:
return "false"
if isinstance(obj, (int, float)):
if obj.is_integer():
obj = int(obj)
return str(obj)
return obj

View File

@@ -5,6 +5,8 @@ from src.ast.expr import Expr, BinaryExpr, UnaryExpr, LiteralExpr, GroupingExpr,
from src.ast.stmt import Stmt, ExpressionStmt, LetStmt, BlockStmt, IfStmt, WhileStmt, ForStmt, FunctionStmt, \ from src.ast.stmt import Stmt, ExpressionStmt, LetStmt, BlockStmt, IfStmt, WhileStmt, ForStmt, FunctionStmt, \
ReturnStmt, BreakStmt, ContinueStmt, ClassStmt ReturnStmt, BreakStmt, ContinueStmt, ClassStmt
from src.consts import MAX_FUNCTION_ARGS from src.consts import MAX_FUNCTION_ARGS
from src.core.format_spec.parser import FormatSpecParser
from src.core.format_spec.spec import FormatSpec
from src.parser.error import ParsingError from src.parser.error import ParsingError
from src.pebble import Pebble from src.pebble import Pebble
from src.token.token import Token, TokenType from src.token.token import Token, TokenType
@@ -391,14 +393,17 @@ class Parser:
def fstring(self) -> Expr: def fstring(self) -> Expr:
start: Token = self.previous() start: Token = self.previous()
parts: list[Expr] = [] parts: list[LiteralExpr | FStringEmbedExpr] = []
while not self.check(TokenType.FSTRING_END) and not self.is_at_end(): while not self.check(TokenType.FSTRING_END) and not self.is_at_end():
if self.match(TokenType.LEFT_BRACE): if self.match(TokenType.LEFT_BRACE):
brace: Token = self.previous() brace: Token = self.previous()
expr: Expr = self.expression() expr: Expr = self.expression()
spec: Optional[FormatSpec] = None
if self.match(TokenType.FORMAT_SPEC):
spec = FormatSpecParser(self.previous().value).parse()
self.consume(TokenType.RIGHT_BRACE, "Expected '}' after f-string embed") self.consume(TokenType.RIGHT_BRACE, "Expected '}' after f-string embed")
parts.append(FStringEmbedExpr(brace, expr, self.previous())) parts.append(FStringEmbedExpr(brace, expr, spec, self.previous()))
else: else:
self.consume(TokenType.FSTRING_TEXT, "Unexpected token") self.consume(TokenType.FSTRING_TEXT, "Unexpected token")
parts.append(LiteralExpr(self.previous().value)) parts.append(LiteralExpr(self.previous().value))