From 2bfde130e8c182cb28549b6608067ff6592cf4c4 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Sun, 8 Feb 2026 01:08:27 +0100 Subject: [PATCH] feat(fstring): add alignment --- examples/basic/23_format_spec.peb | 11 +++++++ src/core/format_spec/lexer.py | 8 ++++- src/core/format_spec/parser.py | 41 +++++++++++++++++++++--- src/core/format_spec/spec.py | 7 ++++ src/core/format_spec/string_formatter.py | 37 +++++++++++++++++---- src/core/format_spec/token.py | 6 ++++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/examples/basic/23_format_spec.peb b/examples/basic/23_format_spec.peb index d980035..e9055f5 100644 --- a/examples/basic/23_format_spec.peb +++ b/examples/basic/23_format_spec.peb @@ -45,4 +45,15 @@ print(f"{d2:e}") print(f"{0:e}") print() +print("Alignment") +print(f"{"test":10}") +print(f"{"test":<10}") +print(f"{"test":>10}") +print(f"{"test":^10}") +print() +print(f"{"test":<<9}") +print(f"{"test":>>9}") +print(f"{"test":^^9}") +print() + print("Complex") \ No newline at end of file diff --git a/src/core/format_spec/lexer.py b/src/core/format_spec/lexer.py index 0305d69..edec7d2 100644 --- a/src/core/format_spec/lexer.py +++ b/src/core/format_spec/lexer.py @@ -77,6 +77,12 @@ class FormatSpecLexer: def scan_token(self): char: str = self.advance() match char: + case "<": + self.add_token(TokenType.LEFT) + case ">": + self.add_token(TokenType.RIGHT) + case "^": + self.add_token(TokenType.CENTER) case "+": self.add_token(TokenType.PLUS) case "-": @@ -111,7 +117,7 @@ class FormatSpecLexer: if char.isdigit(): self.scan_number() else: - self.error("Unexpected character") + self.add_token(TokenType.CHAR) return None def scan_number(self): diff --git a/src/core/format_spec/parser.py b/src/core/format_spec/parser.py index 1e4c732..2883630 100644 --- a/src/core/format_spec/parser.py +++ b/src/core/format_spec/parser.py @@ -1,7 +1,7 @@ from typing import Optional from src.core.format_spec.spec import FormatSpec, FormatSpecOptions, FormatSpecNumber, FormatSpecIntegral, \ - FormatSpecDecimal + FormatSpecDecimal, FormatSpecAlignment from src.core.format_spec.token import Token, TokenType from src.parser.error import ParsingError from src.pebble import Pebble @@ -20,6 +20,18 @@ class FormatSpecParser: TokenType.T_PCT, } + ALIGNMENT: set[TokenType] = { + TokenType.LEFT, + TokenType.RIGHT, + TokenType.CENTER + } + + SIGN: set[TokenType] = { + TokenType.PLUS, + TokenType.MINUS, + TokenType.SPACE + } + def __init__(self, tokens: list[Token]): self.tokens: list[Token] = tokens self.current: int = 0 @@ -68,22 +80,43 @@ class FormatSpecParser: def spec(self) -> FormatSpec: options: FormatSpecOptions = self.options() number: FormatSpecNumber = self.number() - type: Optional[Token] = self.type() + fmt_type: Optional[Token] = self.type() return FormatSpec( options=options, number=number, - type=type + type=fmt_type ) def options(self) -> FormatSpecOptions: + alignment: Optional[FormatSpecAlignment] = self.alignment() sign: Optional[Token] = None - if self.match(TokenType.PLUS, TokenType.MINUS, TokenType.SPACE): + if self.match(*self.SIGN): sign = self.previous() return FormatSpecOptions( + alignment=alignment, sign=sign ) + def alignment(self) -> Optional[FormatSpecAlignment]: + if not self.match(TokenType.CHAR, *self.ALIGNMENT): + return None + fill: Optional[Token] = None + align: Token = self.previous() + + if self.match(*self.ALIGNMENT): + fill = align + align = self.previous() + + if align.type not in self.ALIGNMENT: + self.error(align, "Fill character without alignment.") + return None + + return FormatSpecAlignment( + fill=fill, + align=align + ) + def number(self) -> FormatSpecNumber: integral: FormatSpecIntegral = self.integral() decimal: Optional[FormatSpecDecimal] = self.decimal() diff --git a/src/core/format_spec/spec.py b/src/core/format_spec/spec.py index 1a7f916..e5d21f5 100644 --- a/src/core/format_spec/spec.py +++ b/src/core/format_spec/spec.py @@ -4,8 +4,15 @@ from typing import Optional from src.core.format_spec.token import Token +@dataclass(frozen=True) +class FormatSpecAlignment: + fill: Optional[Token] + align: Optional[Token] + + @dataclass(frozen=True) class FormatSpecOptions: + alignment: Optional[FormatSpecAlignment] sign: Optional[Token] diff --git a/src/core/format_spec/string_formatter.py b/src/core/format_spec/string_formatter.py index deac5a9..fc16874 100644 --- a/src/core/format_spec/string_formatter.py +++ b/src/core/format_spec/string_formatter.py @@ -1,7 +1,7 @@ from math import log10, floor from typing import Any, Optional -from src.core.format_spec.spec import FormatSpec +from src.core.format_spec.spec import FormatSpec, FormatSpecAlignment from src.core.format_spec.token import TokenType, Token from src.interpreter.error import PebbleRuntimeError @@ -69,6 +69,8 @@ class StringFormatter: else: fmt_type, obj = self.guess_type(obj) + align_side: TokenType = TokenType.LEFT + fill_char: str = " " match fmt_type: case None: res = self.stringify(obj) @@ -76,15 +78,25 @@ class StringFormatter: res = obj case TokenType.T_BIN | TokenType.T_DEC | TokenType.T_HEX | TokenType.T_HEX_CAPS | TokenType.T_OCT: res = self.format_int(obj, spec, fmt_type) + align_side = TokenType.RIGHT case TokenType.T_FIX: res = self.format_float_fix(obj, spec, fmt_type) + align_side = TokenType.RIGHT case TokenType.T_PCT: res = self.format_float_fix(obj * 100, spec, TokenType.T_FIX) + "%" + align_side = TokenType.RIGHT case TokenType.T_SCI: res = self.format_float_sci(obj, spec, fmt_type) + align_side = TokenType.RIGHT + + align_spec: Optional[FormatSpecAlignment] = spec.options.alignment + if align_spec is not None: + align_side = align_spec.align.type + if align_spec.fill is not None: + fill_char = align_spec.fill.lexeme if spec.number.integral.width is not None: - res = self.pad(res, spec.number.integral.width) + res = self.pad(res, spec.number.integral.width, align_side, fill_char) return res def format_int(self, value: int, spec: FormatSpec, fmt_type: TokenType) -> str: @@ -168,8 +180,21 @@ class StringFormatter: groups.append(string[max(0, length - i - group_size):length - i]) return sep.join(reversed(groups)) - def pad(self, string: str, width: int) -> str: + def pad(self, string: str, width: int, side: TokenType, char: str) -> str: to_pad: int = width - len(string) - if to_pad > 0: - string = " " * to_pad + string - return string + if to_pad <= 0: + return string + left: int = 0 + right: int = 0 + match side: + case TokenType.LEFT: + left = 0 + right = to_pad + case TokenType.RIGHT: + left = to_pad + right = 0 + case TokenType.CENTER: + left = to_pad // 2 + right = to_pad - left + + return char * left + string + char * right diff --git a/src/core/format_spec/token.py b/src/core/format_spec/token.py index d41559e..fc27c88 100644 --- a/src/core/format_spec/token.py +++ b/src/core/format_spec/token.py @@ -6,6 +6,11 @@ from src.core.position import Position class TokenType(Enum): + # Align + LEFT = auto() + RIGHT = auto() + CENTER = auto() + # Sign PLUS = auto() MINUS = auto() @@ -35,6 +40,7 @@ class TokenType(Enum): NUMBER = auto() DOT = auto() EOF = auto() + CHAR = auto() @dataclass(frozen=True)