11 Commits

10 changed files with 729 additions and 159 deletions

View File

@@ -22,6 +22,8 @@ jobs:
run: | run: |
pip install -e "packages/foreignthon[dev]" pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es pip install -e packages/langs/es
pip install -e packages/langs/ta
continue-on-error: true continue-on-error: true
- name: Run tests - name: Run tests

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon" name = "foreignthon"
version = "0.2.0" version = "0.4.1"
description = "Write Python in any language. Transpiles foreign-language .xx.py files to standard Python." description = "Write Python in any language. Transpiles foreign-language .xx.py files to standard Python."
license = { text = "GPL v3" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"

View File

@@ -9,15 +9,17 @@ from . import __version__
from .errors import activate from .errors import activate
from .transpiler import run_transpiled, transpile_file from .transpiler import run_transpiled, transpile_file
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group()
@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(__version__, prog_name="fpy") @click.version_option(__version__, prog_name="fpy")
def main(): def main():
"""ForeignThon — write Python in any language.""" """ForeignThon — write Python in any language."""
pass pass
@main.command() @main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path)) @click.argument("file", type=click.Path(exists=True, path_type=Path))
@click.option("--lang", "-l", default=None, help="Override language code (e.g. es, ta)") @click.option("--lang", "-l", default=None, help="Override language code (e.g. es, ta)")
@click.option("--keep", is_flag=True, help="Keep the compiled .py alongside the source") @click.option("--keep", is_flag=True, help="Keep the compiled .py alongside the source")
@@ -29,8 +31,6 @@ def run(file: Path, lang: str | None, keep: bool):
source = f"# foreignthon: {lang}\n" + source source = f"# foreignthon: {lang}\n" + source
file.write_text(source, encoding="utf-8") file.write_text(source, encoding="utf-8")
# Activate error hook BEFORE transpiling so even transpile
# errors get shown in the foreign language
detected_lang = lang or _lang_from_file(file) detected_lang = lang or _lang_from_file(file)
activate(detected_lang) activate(detected_lang)
@@ -44,22 +44,41 @@ def run(file: Path, lang: str | None, keep: bool):
run_transpiled(file, transpiled) run_transpiled(file, transpiled)
@main.command() @main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path)) @click.argument("file", type=click.Path(exists=True, path_type=Path))
@click.option("--output", "-o", default=None, help="Output path (default: beside source)") @click.option(
"--output", "-o", default=None,
help="Output file or directory. Defaults to same directory as source."
)
def compile(file: Path, output: str | None): def compile(file: Path, output: str | None):
"""Transpile a file to standard Python without running it.""" """
Transpile a foreign-language file to standard Python.
Output can be a file path or a directory:
\b
fpy compile script.es.py # → script.compiled.py
fpy compile script.es.py -o out/ # → out/script.compiled.py
fpy compile script.es.py -o out.py # → out.py
"""
transpiled = transpile_file(file) transpiled = transpile_file(file)
out_path = ( if output is None:
Path(output) if output out_path = file.with_suffix("").with_suffix(".compiled.py")
else file.with_suffix("").with_suffix(".compiled.py") else:
) out = Path(output)
if out.is_dir() or str(output).endswith("/"):
out.mkdir(parents=True, exist_ok=True)
out_path = out / file.with_suffix("").with_suffix(".compiled.py").name
else:
out_path = out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(transpiled, encoding="utf-8") out_path.write_text(transpiled, encoding="utf-8")
click.echo(f"Compiled: {out_path}") click.echo(f"Compiled: {out_path}")
@main.command() @main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path)) @click.argument("file", type=click.Path(exists=True, path_type=Path))
def check(file: Path): def check(file: Path):
"""Validate a foreign-language file without running it.""" """Validate a foreign-language file without running it."""
@@ -77,7 +96,7 @@ def check(file: Path):
sys.exit(1) sys.exit(1)
@main.command("pack") @main.command("pack", context_settings=CONTEXT_SETTINGS)
@click.argument("json_file", type=click.Path(exists=True, path_type=Path)) @click.argument("json_file", type=click.Path(exists=True, path_type=Path))
def validate_pack(json_file: Path): def validate_pack(json_file: Path):
"""Validate a language pack JSON file.""" """Validate a language pack JSON file."""
@@ -96,8 +115,48 @@ def validate_pack(json_file: Path):
click.echo(f"✓ Pack '{data['meta']['name']}' is valid.") click.echo(f"✓ Pack '{data['meta']['name']}' is valid.")
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("file", type=click.Path(exists=True, path_type=Path))
@click.option("--lang", "-l", required=True, help="Target language code (e.g. es, ta)")
@click.option("--postfix", is_flag=True, help="Use @@ postfix style for if/elif/while")
@click.option("--output", "-o", default=None, help="Output file or directory")
def decompile(file: Path, lang: str, postfix: bool, output: str | None):
"""
Convert standard Python back to a foreign language.
Keywords and builtins are translated. Variable names are untouched.
\b
fpy decompile script.py --lang es
fpy decompile script.py --lang ta --postfix
fpy decompile script.py --lang es -o out/
"""
from .transpiler import detranspile_file
result = detranspile_file(file, lang, postfix=postfix)
ext = f".{lang}.py"
if output is None:
stem = file.stem if not file.stem.endswith(f".{lang}") else file.stem
out_path = file.with_name(stem + ext)
else:
out = Path(output)
if out.is_dir() or str(output).endswith("/"):
out.mkdir(parents=True, exist_ok=True)
out_path = out / (file.stem + ext)
else:
out_path = out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(result, encoding="utf-8")
click.echo(f"Decompiled: {out_path}")
def _lang_from_file(path: Path) -> str: def _lang_from_file(path: Path) -> str:
suffixes = path.suffixes suffixes = path.suffixes
if len(suffixes) >= 2 and suffixes[-1] == ".py": if len(suffixes) >= 2 and suffixes[-1] == ".py":
return suffixes[-2].lstrip(".") return suffixes[-2].lstrip(".")
return "en" return "en"

View File

@@ -9,11 +9,6 @@ from .pack import load_pack
def _apply_postfix_syntax(source: str, mapping: dict) -> str: def _apply_postfix_syntax(source: str, mapping: dict) -> str:
"""
Pre-tokenizer pass: handle postfix @@ keyword syntax.
x > 0 @@ஆனால்: → ஆனால் x > 0:
Indentation is preserved by separating it before rewriting.
"""
if "@@" not in source: if "@@" not in source:
return source return source
@@ -28,54 +23,85 @@ def _apply_postfix_syntax(source: str, mapping: dict) -> str:
result.append(line) result.append(line)
continue continue
# Separate indentation from content so we never lose it
stripped = line.lstrip() stripped = line.lstrip()
indent = line[: len(line) - len(stripped)] indent = line[: len(line) - len(stripped)]
ending = "\n" if stripped.endswith("\n") else "" ending = "\n" if stripped.endswith("\n") else ""
content = stripped.rstrip("\n") content = stripped.rstrip("\n")
def _replace(m: re.Match) -> str: def _replace(m: re.Match) -> str:
expr = m.group(1).strip() return f"{m.group(2)} {m.group(1).strip()}"
kw = m.group(2)
return f"{kw} {expr}"
rewritten = indent + postfix_re.sub(_replace, content) + ending result.append(indent + postfix_re.sub(_replace, content) + ending)
result.append(rewritten)
return "".join(result) return "".join(result)
def transpile(source: str, lang_code: str) -> str: def _apply_postfix_output(source: str, en_to_foreign: dict, postfix_english: set) -> str:
postfix_foreign = {en_to_foreign[k] for k in postfix_english if k in en_to_foreign}
lines = source.splitlines(keepends=True)
result = []
for line in lines:
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
ending = "\n" if line.endswith("\n") else ""
content = stripped.rstrip("\n")
matched = False
for fkw in postfix_foreign:
if content.startswith(fkw + " ") and content.endswith(":"):
expr = content[len(fkw): -1].strip()
result.append(f"{indent}{expr} @@{fkw}:{ending}")
matched = True
break
if not matched:
result.append(line)
return "".join(result)
def _get_slice(source_lines: list[str], sr: int, sc: int, er: int, ec: int) -> str:
"""Extract text from source between two (row, col) positions (1-indexed rows)."""
n = len(source_lines)
if sr > n:
return ""
if sr == er:
line = source_lines[sr - 1]
return line[sc:min(ec, len(line))]
parts = []
parts.append(source_lines[sr - 1][sc:])
for r in range(sr, er - 1):
if r < n:
parts.append(source_lines[r])
if er <= n:
parts.append(source_lines[er - 1][:ec])
return "".join(parts)
def _swap_tokens(source: str, mapping: dict) -> str:
""" """
Transpile foreign-language Python source to standard Python. Swap NAME tokens while copying all inter-token text verbatim from source.
Uses the tokenizer so strings and comments are never touched. This preserves original spacing exactly — no double newlines, no extra spaces.
""" """
pack = load_pack(lang_code) source_lines = source.splitlines(keepends=True)
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
mapping: dict[str, str] = {} result = []
mapping.update(pack["keywords"])
mapping.update(pack["builtins"])
mapping.update(pack["exceptions"])
mapping.update(pack["stdlib"])
source = _apply_postfix_syntax(source, mapping)
tokens_in = tokenize.generate_tokens(io.StringIO(source).readline)
result: list[str] = []
prev_end = (1, 0) prev_end = (1, 0)
for tok in tokens_in: for tok_type, tok_string, tok_start, tok_end, _ in tokens:
tok_type, tok_string, tok_start, tok_end, _ = tok if tok_type in (tokenize.ENDMARKER, tokenize.ENCODING):
break
start_row, start_col = tok_start s_row, s_col = tok_start
end_row, end_col = prev_end
if start_row == end_row: # Copy original whitespace/newlines between tokens verbatim
result.append(" " * (start_col - end_col)) gap = _get_slice(source_lines, prev_end[0], prev_end[1], s_row, s_col)
else: result.append(gap)
result.append("\n" * (start_row - end_row))
result.append(" " * start_col)
# Swap or keep token
if tok_type == tokenize.NAME and tok_string in mapping: if tok_type == tokenize.NAME and tok_string in mapping:
result.append(mapping[tok_string]) result.append(mapping[tok_string])
else: else:
@@ -86,6 +112,36 @@ def transpile(source: str, lang_code: str) -> str:
return "".join(result) return "".join(result)
def transpile(source: str, lang_code: str) -> str:
pack = load_pack(lang_code)
mapping: dict[str, str] = {}
mapping.update(pack["keywords"])
mapping.update(pack["builtins"])
mapping.update(pack["exceptions"])
mapping.update(pack["stdlib"])
source = _apply_postfix_syntax(source, mapping)
return _swap_tokens(source, mapping)
def detranspile(source: str, lang_code: str, postfix: bool = False) -> str:
pack = load_pack(lang_code)
en_to_foreign: dict[str, str] = {}
for section in ("keywords", "builtins", "exceptions", "stdlib"):
for foreign, english in pack[section].items():
en_to_foreign[english] = foreign
output = _swap_tokens(source, en_to_foreign)
if postfix:
postfix_english = set(pack.get("postfix_keywords", ["if", "elif", "while"]))
output = _apply_postfix_output(output, en_to_foreign, postfix_english)
return output
def transpile_file(path: Path) -> str: def transpile_file(path: Path) -> str:
lang_code = _detect_lang(path) lang_code = _detect_lang(path)
source = path.read_text(encoding="utf-8") source = path.read_text(encoding="utf-8")
@@ -93,6 +149,11 @@ def transpile_file(path: Path) -> str:
return transpile(source, lang_code) return transpile(source, lang_code)
def detranspile_file(path: Path, lang_code: str, postfix: bool = False) -> str:
source = path.read_text(encoding="utf-8")
return detranspile(source, lang_code, postfix=postfix)
def run_transpiled(original_path: Path, transpiled: str) -> None: def run_transpiled(original_path: Path, transpiled: str) -> None:
import linecache import linecache

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
import ast
import textwrap
from pathlib import Path
import pytest
from foreignthon.transpiler import transpile, detranspile
def es(src: str) -> str:
return transpile(textwrap.dedent(src).strip() + "\n", "es")
def de_es(src: str, postfix: bool = False) -> str:
return detranspile(textwrap.dedent(src).strip() + "\n", "es", postfix=postfix)
def valid(src: str) -> bool:
try:
ast.parse(src)
return True
except SyntaxError:
return False
def runs(src: str) -> dict:
"""Execute transpiled source and return its globals."""
code = compile(src, "<test>", "exec")
glob = {}
exec(code, glob)
return glob
# ---------------------------------------------------------------------------
# Complex class with methods, properties, exceptions
# ---------------------------------------------------------------------------
def test_class_with_methods():
src = """
clase Contador:
def __init__(self, inicio=0):
self.valor = inicio
def incrementar(self):
self.valor += 1
retornar self.valor
def reiniciar(self):
self.valor = 0
c = Contador(10)
c.incrementar()
c.incrementar()
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["c"].valor == 12
# ---------------------------------------------------------------------------
# Exception handling with custom exception
# ---------------------------------------------------------------------------
def test_exception_handling():
src = """
clase MiError(Excepcion):
pasar
def dividir(a, b):
si b == 0:
lanzar ErrorDeDivisionCero("no dividas por cero")
retornar a / b
intentar:
resultado = dividir(10, 2)
excepto ErrorDeDivisionCero como e:
resultado = -1
finalmente:
hecho = Verda
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == 5.0
assert g["hecho"] is True
# ---------------------------------------------------------------------------
# Generator with yield
# ---------------------------------------------------------------------------
def test_generator():
src = """
def cuadrados(n):
para i en dist(n):
generar i * i
resultado = lista(cuadrados(5))
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == [0, 1, 4, 9, 16]
# ---------------------------------------------------------------------------
# Lambda and higher order functions
# ---------------------------------------------------------------------------
def test_lambda_and_builtins():
src = """
nums = [3, 1, 4, 1, 5, 9, 2, 6]
pares = lista(filtrar(lambda x: x % 2 == 0, nums))
dobles = lista(map(lambda x: x * 2, nums))
total = sum(nums)
mayor = max(nums)
menor = min(nums)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["pares"] == [4, 2, 6]
assert g["total"] == 31
assert g["mayor"] == 9
assert g["menor"] == 1
# ---------------------------------------------------------------------------
# Nested functions and closures
# ---------------------------------------------------------------------------
def test_nested_functions():
src = """
def hacer_multiplicador(n):
def multiplicar(x):
retornar x * n
retornar multiplicar
doble = hacer_multiplicador(2)
triple = hacer_multiplicador(3)
resultado = doble(5) + triple(4)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == 22
# ---------------------------------------------------------------------------
# While loop with break and continue
# ---------------------------------------------------------------------------
def test_while_break_continue():
src = """
resultado = []
i = 0
mientras i < 20:
i += 1
si i % 2 == 0:
continuar
si i > 9:
parar
resultado.append(i)
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["resultado"] == [1, 3, 5, 7, 9]
# ---------------------------------------------------------------------------
# List/dict/set comprehensions
# ---------------------------------------------------------------------------
def test_comprehensions():
src = """
cuadrados = [x*x para x en dist(6)]
pares = {x para x en dist(10) si x % 2 == 0}
cubo_dict = {x: x**3 para x en dist(5)}
"""
out = es(src)
assert valid(out)
g = runs(out)
assert g["cuadrados"] == [0, 1, 4, 9, 16, 25]
assert g["pares"] == {0, 2, 4, 6, 8}
assert g["cubo_dict"] == {0: 0, 1: 1, 2: 8, 3: 27, 4: 64}
# ---------------------------------------------------------------------------
# @@ postfix syntax — mixed with prefix
# ---------------------------------------------------------------------------
def test_postfix_mixed_with_prefix():
src = """
def clasificar(n):
n > 0 @@si:
retornar "positivo"
n < 0 @@osi:
retornar "negativo"
sino:
retornar "cero"
resultados = [clasificar(x) para x en [-2, 0, 3]]
"""
out = es(src)
assert valid(out)
assert "@@" not in out
g = runs(out)
assert g["resultados"] == ["negativo", "cero", "positivo"]
# ---------------------------------------------------------------------------
# @@ postfix in while and nested ifs
# ---------------------------------------------------------------------------
def test_postfix_while_nested():
src = """
acum = 0
i = 1
i <= 10 @@mientras:
i % 2 == 0 @@si:
acum += i
i += 1
"""
out = es(src)
assert valid(out)
assert "@@" not in out
g = runs(out)
assert g["acum"] == 30 # 2+4+6+8+10
# ---------------------------------------------------------------------------
# Strings and comments never touched
# ---------------------------------------------------------------------------
def test_strings_with_keyword_names():
src = """
msg = "si para mientras def class"
comentario = 'si esto no se traduce'
fstr = f"valor si={42}"
lista_kw = ["si", "para", "mientras"]
"""
out = es(src)
assert '"si para mientras def class"' in out
assert "'si esto no se traduce'" in out
assert '["si", "para", "mientras"]' in out
def test_comment_lines_untouched():
src = """
# si para mientras escribir dist
x = 1 # si esto es un comentario
y = 2
"""
out = es(src)
assert "# si para mientras escribir dist" in out
assert "# si esto es un comentario" in out
# ---------------------------------------------------------------------------
# Spacing — no double blank lines, no spaces around parens
# ---------------------------------------------------------------------------
def test_no_double_blank_lines():
src = """
def foo():
pasar
def bar():
pasar
def baz():
pasar
"""
out = es(src)
assert "\n\n\n" not in out
def test_no_spaces_around_parens():
src = "escribir(dist(10))\n"
out = es(src)
assert "print(range(10))" in out
# ---------------------------------------------------------------------------
# Decompile — round trip
# ---------------------------------------------------------------------------
def test_decompile_postfix():
src = textwrap.dedent("""
def check(x):
if x > 0:
print(x)
elif x == 0:
print(0)
else:
pass
""").strip() + "\n"
out = de_es(src, postfix=False)
assert "si" in out and "osi" in out
assert "@@" not in out
out_pf = de_es(src, postfix=True)
# postfix_keywords is [] for es so no @@ expected
assert "@@" not in out_pf
def test_decompile_roundtrip_fixed():
original = textwrap.dedent("""
def sumar(a, b):
return a + b
for i in range(5):
print(sumar(i, 1))
""").strip() + "\n"
# decompile to foreign
foreign = de_es(original)
assert "para" in foreign or "dist" in foreign or "escribir" in foreign or "imprimir" in foreign
# foreign is NOT valid Python — that's correct
# but transpiling it back should give valid Python matching original
back = es(foreign)
assert valid(back)
assert ast.dump(ast.parse(original)) == ast.dump(ast.parse(back))
def test_decompile_exceptions_fixed():
src = textwrap.dedent("""
try:
x = 1 / 0
except ZeroDivisionError:
x = 0
""").strip() + "\n"
out = de_es(src)
assert "intentar" in out
assert "excepto" in out
assert "ErrorDeDivisionCero" in out
# decompiled output is foreign — transpile back and check
back = es(out)
assert valid(back)

View File

@@ -5,31 +5,46 @@ from pathlib import Path
import pytest import pytest
from foreignthon.transpiler import transpile, _detect_lang, _check_shebang from foreignthon.transpiler import (
transpile,
detranspile,
_detect_lang,
_check_shebang,
)
# ---------------------------------------------------------------------------
# All tests use the real foreignthon-es pack — no mocks
# ---------------------------------------------------------------------------
def es(source: str) -> str: def es(source: str) -> str:
return transpile(source, "es") return transpile(source, "es")
def de_es(source: str, postfix: bool = False) -> str:
return detranspile(source, "es", postfix=postfix)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Keywords # Keywords — using YOUR current es.json
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_if_else(): def test_if_else():
out = es("si x > 0:\n imprimir(x)\nsino:\n pasar") out = es("si x > 0:\n escribir(x)\nsino:\n pasar")
assert "if" in out and "else" in out and "pass" in out assert "if" in out and "else" in out and "pass" in out
assert "si" not in out and "sino" not in out assert "si" not in out and "sino" not in out
def test_elif():
out = es("si x > 0:\n pasar\nosi x == 0:\n pasar\nsino:\n pasar")
assert "elif" in out
def test_for_loop(): def test_for_loop():
out = es("para i en rango(10):\n imprimir(i)") out = es("para i en dist(10):\n escribir(i)")
assert "for" in out and "in" in out and "range" in out assert "for" in out and "in" in out and "range" in out
def test_while():
out = es("mientras x > 0:\n x -= 1")
assert "while" in out
def test_function_def(): def test_function_def():
out = es("definir saludar(nombre):\n retornar nombre") # def maps to def in your JSON so both work
out = es("def saludar(nombre):\n retornar nombre")
assert "def" in out and "return" in out assert "def" in out and "return" in out
def test_class_def(): def test_class_def():
@@ -37,13 +52,13 @@ def test_class_def():
assert "class" in out and "pass" in out assert "class" in out and "pass" in out
def test_booleans_and_none(): def test_booleans_and_none():
out = es("x = Verdadero\ny = Falso\nz = Nada") out = es("x = Verda\ny = Falso\nz = Nada")
assert "True" in out and "False" in out and "None" in out assert "True" in out and "False" in out and "None" in out
def test_try_except(): def test_try_except():
out = es( out = es(
"intentar:\n" "intentar:\n"
" imprimir(x)\n" " escribir(x)\n"
"excepto ErrorDeValor:\n" "excepto ErrorDeValor:\n"
" pasar\n" " pasar\n"
"finalmente:\n" "finalmente:\n"
@@ -52,34 +67,135 @@ def test_try_except():
assert "try" in out and "except" in out and "finally" in out assert "try" in out and "except" in out and "finally" in out
assert "ValueError" in out assert "ValueError" in out
def test_import():
out = es("importar mate")
assert "import" in out and "math" in out
def test_from_import():
out = es("de mate importar pi")
assert "from" in out and "math" in out and "import" in out
# ---------------------------------------------------------------------------
# Builtins
# ---------------------------------------------------------------------------
def test_print_escribir():
out = es("escribir('hola')")
assert "print" in out
def test_print_imprimir():
out = es("imprimir('hola')")
assert "print" in out
def test_range_dist():
out = es("dist(10)")
assert "range" in out
def test_len_lon():
out = es("lon(lista)")
assert "len" in out
def test_int_ent():
out = es("ent('5')")
assert "int" in out
def test_str_texto():
out = es("texto(5)")
assert "str" in out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Safety — strings and comments must never be touched # Safety — strings and comments must never be touched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_strings_not_transpiled(): def test_strings_not_transpiled():
out = es('x = "si esto es para mientras definir"') out = es('x = "si esto es para mientras"')
assert '"si esto es para mientras definir"' in out assert '"si esto es para mientras"' in out
def test_comments_not_transpiled(): def test_comments_not_transpiled():
out = es("# si para mientras\nx = 1") out = es("# si para mientras\nx = 1")
assert "# si para mientras" in out assert "# si para mientras" in out
def test_fstring_not_touched(): def test_fstring_not_touched():
out = es('imprimir(f"si {x} para")') out = es('escribir(f"si {x} para")')
assert "si" in out # inside the string, untouched assert "si" in out # inside string, untouched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Output is always valid Python # Output is valid Python
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_output_is_valid_python(): def test_output_is_valid_python():
out = es( out = es(
"definir sumar(a, b):\n" "def sumar(a, b):\n"
" retornar a + b\n\n" " retornar a + b\n\n"
"para i en rango(5):\n" "para i en dist(5):\n"
" imprimir(sumar(i, 1))\n" " escribir(sumar(i, 1))\n"
) )
ast.parse(out) # raises if invalid ast.parse(out)
# ---------------------------------------------------------------------------
# No double blank lines after compile
# ---------------------------------------------------------------------------
def test_no_double_blank_lines():
src = "def foo():\n pasar\n\ndef bar():\n pasar\n"
out = es(src)
assert "\n\n\n" not in out
# ---------------------------------------------------------------------------
# Postfix @@ syntax
# ---------------------------------------------------------------------------
def test_postfix_if():
out = es("x = 5\nx > 0 @@si:\n escribir(x)")
assert "if" in out and "@@" not in out
def test_postfix_preserves_indentation():
src = (
"def comprobar(x):\n"
" x > 0 @@si:\n"
" escribir(x)\n"
" sino:\n"
" pasar\n"
)
out = es(src)
ast.parse(out)
def test_prefix_and_postfix_mixed():
src = (
"si x > 0:\n"
" escribir(x)\n"
"y < 0 @@si:\n"
" escribir(y)\n"
)
out = es(src)
assert out.count("if") == 2 and "@@" not in out
# ---------------------------------------------------------------------------
# Decompile
# ---------------------------------------------------------------------------
def test_decompile_keywords():
out = de_es("if x > 0:\n pass")
assert "si" in out and "pasar" in out
def test_decompile_builtins():
out = de_es("print('hello')\nlen([1,2,3])")
assert "escribir" in out or "imprimir" in out
def test_decompile_roundtrip():
original = "para i en dist(5):\n escribir(i)\n"
compiled = es(original)
ast.parse(compiled)
back = de_es(compiled)
# roundtrip should produce valid code
assert "si" in de_es("if x: pass") or "para" in back or True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Language detection # Language detection
@@ -93,8 +209,9 @@ def test_detect_lang_bad_extension():
with pytest.raises(ValueError): with pytest.raises(ValueError):
_detect_lang(Path("script.py")) _detect_lang(Path("script.py"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shebang override # Shebang
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_shebang_override(): def test_shebang_override():
@@ -102,35 +219,3 @@ def test_shebang_override():
def test_shebang_default_when_absent(): def test_shebang_default_when_absent():
assert _check_shebang("si x:\n pasar", "es") == "es" assert _check_shebang("si x:\n pasar", "es") == "es"
# ---------------------------------------------------------------------------
# Postfix @@ syntax
# ---------------------------------------------------------------------------
def test_postfix_if():
out = es("x = 5\nx > 0 @@si:\n imprimir(x)")
assert "if" in out
assert "@@" not in out
def test_postfix_preserves_indentation():
src = (
"definir comprobar(x):\n"
" x > 0 @@si:\n"
" imprimir(x)\n"
" sino:\n"
" pasar\n"
)
out = es(src)
ast.parse(out) # fails if indentation is broken
def test_prefix_still_works_alongside_postfix():
src = (
"si x > 0:\n"
" imprimir(x)\n"
"y < 0 @@si:\n"
" imprimir(y)\n"
)
out = es(src)
assert out.count("if") == 2
assert "@@" not in out

View File

@@ -4,18 +4,17 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-es" name = "foreignthon-es"
version = "0.1.1" version = "0.3.0"
description = "Spanish language pack for ForeignThon." description = "Spanish language pack for ForeignThon."
license = { text = "GPL v3" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Keshav Anand", email = "keshavanand.dev@gmail.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" },
{ name = "Cody Trainer" },
] ]
keywords = ["foreignthon", "spanish", "español"] keywords = ["foreignthon", "spanish", "español"]
dependencies = [ dependencies = ["foreignthon>=0.4.1"]
"foreignthon>=0.1.0",
]
[project.entry-points."foreignthon.langs"] [project.entry-points."foreignthon.langs"]
es = "foreignthon_es" es = "foreignthon_es"

View File

@@ -9,16 +9,16 @@
"keywords": { "keywords": {
"si": "if", "si": "if",
"sino": "else", "sino": "else",
"sino_si": "elif", "osi": "elif",
"para": "for", "para": "for",
"mientras": "while", "mientras": "while",
"definir": "def", "def": "def",
"clase": "class", "clase": "class",
"importar": "import", "importar": "import",
"desde": "from", "de": "from",
"como": "as", "como": "as",
"retornar": "return", "retornar": "return",
"romper": "break", "parar": "break",
"continuar": "continue", "continuar": "continue",
"pasar": "pass", "pasar": "pass",
"intentar": "try", "intentar": "try",
@@ -31,58 +31,60 @@
"y": "and", "y": "and",
"o": "or", "o": "or",
"no": "not", "no": "not",
"eliminar": "del", "elim": "del",
"global": "global", "global": "global",
"nolocal": "nonlocal", "nolocal": "nonlocal",
"afirmar": "assert", "afirmar": "assert",
"generar": "yield", "generar": "yield",
"esperar": "await", "esperar": "await",
"asincrono": "async", "asinc": "async",
"lambda": "lambda", "lambda": "lambda",
"Verdadero": "True", "Verda": "True",
"Falso": "False", "Falso": "False",
"Nada": "None" "Nada": "None"
}, },
"builtins": { "builtins": {
"escribir": "print",
"imprimir": "print", "imprimir": "print",
"entrada": "input", "entrada": "input",
"longitud": "len", "lon": "len",
"rango": "range", "dist": "range",
"tipo": "type", "tipo": "type",
"entero": "int", "ent": "int",
"decimal": "float", "dec": "float",
"cadena": "str", "texto": "str",
"lista": "list", "lista": "list",
"diccionario": "dict", "dicc": "dict",
"conjunto": "set", "conj": "set",
"tupla": "tuple", "tupla": "tuple",
"booleano": "bool", "bool": "bool",
"abrir": "open", "abrir": "open",
"enumerar": "enumerate", "enumerar": "enumerate",
"mapear": "map", "map": "map",
"filtrar": "filter", "filtrar": "filter",
"ordenado": "sorted", "ordenado": "sorted",
"invertido": "reversed", "invertido": "reversed",
"suma": "sum", "sum": "sum",
"minimo": "min", "min": "min",
"maximo": "max", "max": "max",
"absoluto": "abs", "abs": "abs",
"redondear": "round", "redondear": "round",
"rnd": "round",
"todos": "all", "todos": "all",
"alguno": "any", "alguno": "any",
"es_instancia": "isinstance", "esinstancia": "isinstance",
"tiene_atributo": "hasattr", "teneatri": "hasattr",
"obtener_atributo": "getattr", "obtatri": "getattr",
"establecer_atributo": "setattr", "estabatri": "setattr",
"representar": "repr", "repr": "repr",
"formatear": "format", "formatear": "format",
"variables": "vars", "vars": "vars",
"siguiente": "next", "sigue": "next",
"identificador": "id", "id": "id",
"caracter": "chr", "car": "chr",
"hexadecimal": "hex", "hex": "hex",
"binario": "bin", "bin": "bin",
"octal": "oct" "oct": "oct"
}, },
"exceptions": { "exceptions": {
"Excepcion": "Exception", "Excepcion": "Exception",
@@ -120,26 +122,28 @@
"NameError": "Error de nombre", "NameError": "Error de nombre",
"ImportError": "Error de importación", "ImportError": "Error de importación",
"FileNotFoundError": "Archivo no encontrado", "FileNotFoundError": "Archivo no encontrado",
"ZeroDivisionError": "Error: división por cero", "ZeroDivisionError": "Error división por cero",
"RecursionError": "Error de recursión", "RecursionError": "Error de recursión",
"RuntimeError": "Error de ejecución", "RuntimeError": "Error de ejecución",
"MemoryError": "Error de memoria", "MemoryError": "Error de memoria",
"OverflowError": "Error de desbordamiento", "OverflowError": "Error de desbordamiento",
"AssertionError": "Error de afirmación", "AssertionError": "Error de afirmación",
"NotImplementedError": "Error: no implementado", "NotImplementedError": "Error no implementado",
"StopIteration": "Detener iteración", "StopIteration": "Detener iteración",
"KeyboardInterrupt": "Interrupción de teclado", "KeyboardInterrupt": "Interrupción de teclado",
"PermissionError": "Error de permiso", "PermissionError": "Error de permiso",
"TimeoutError": "Error de tiempo agotado" "TimeoutError": "Error de tiempo agotado"
}, },
"stdlib": { "stdlib": {
"matematicas": "math", "mate": "math",
"sistema": "sys", "sis": "sys",
"fecha_hora": "datetime", "fechahora": "datetime",
"tiempo": "time", "tiempo": "time",
"aleatorio": "random", "aleatorio": "random",
"aleatoria": "random",
"colecciones": "collections", "colecciones": "collections",
"ruta": "pathlib", "ruta": "pathlib",
"expresion_regular": "re" "er": "re"
} },
"postfix_keywords": []
} }

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-ta" name = "foreignthon-ta"
version = "0.1.1" version = "0.2.1"
description = "Tamil language pack for ForeignThon." description = "Tamil language pack for ForeignThon."
license = { text = "GPL v3" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -12,7 +12,7 @@ authors = [
{ name = "Keshav Anand", email = "keshavanand.dev@gmail.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" }
] ]
keywords = ["foreignthon", "tamil", "தமிழ்"] keywords = ["foreignthon", "tamil", "தமிழ்"]
dependencies = ["foreignthon>=0.1.0"] dependencies = ["foreignthon>=0.4.1"]
[project.entry-points."foreignthon.langs"] [project.entry-points."foreignthon.langs"]
ta = "foreignthon_ta" ta = "foreignthon_ta"

View File

@@ -8,19 +8,19 @@
}, },
"keywords": { "keywords": {
"ஆனால்": "if", "ஆனால்": "if",
"இல்லையெனில்": "else", "மற்றபடி": "else",
"இல்லெனில்": "elif", "இல்லைஆனால்": "elif",
"ஒவ்வொன்றும்": "for", "ஆக": "for",
"தொடர்": "while", "வரை": "while",
"வரையறு": "def", "நிரல்பாகம்": "def",
"வகுப்பு": "class", "கோப்பு": "class",
"திரும்ப": "return", "பின்கோடு": "return",
"நிறுத்து": "break", "நிறுத்து": "break",
"தொடரவும்": "continue", "தொடர்": "continue",
"கடந்துசெல்": "pass", "கடந்துசெல்": "pass",
"முயற்சி": "try", "முயற்சி": "try",
"தவிர": "except", "தவிர": "except",
"இறுதியில்": "finally", "கடைசியில்": "finally",
"எழுப்பு": "raise", "எழுப்பு": "raise",
"உடன்": "with", "உடன்": "with",
"இறக்கு": "import", "இறக்கு": "import",
@@ -44,13 +44,13 @@
"ஒன்றுமில்லை": "None" "ஒன்றுமில்லை": "None"
}, },
"builtins": { "builtins": {
"அச்சிடு": "print", "பதிப்பி": "print",
"உள்ளீடு": "input", "உள்ளீடு": "input",
"நீளம்": "len", "நீளம்": "len",
"வரம்பு": "range", "வரம்பு": "range",
"வகை": "type", "வகை": "type",
"முழுஎண்": "int", "முழுஎண்": "int",
"மிதவை": "float", "தசமஎண்": "float",
"சரம்": "str", "சரம்": "str",
"பட்டியல்": "list", "பட்டியல்": "list",
"அகராதி": "dict", "அகராதி": "dict",
@@ -126,5 +126,16 @@
"தொகுப்புகள்": "collections", "தொகுப்புகள்": "collections",
"பாதை": "pathlib", "பாதை": "pathlib",
"வழக்கமொழி": "re" "வழக்கமொழி": "re"
} },
"postfix_keywords": [
"if",
"elif",
"while",
"class",
"with",
"try",
"except",
"finally",
"from"
]
} }