14 Commits

17 changed files with 742 additions and 245 deletions

View File

@@ -3,7 +3,8 @@ name: Publish
on: on:
push: push:
tags: tags:
- "v*" - "foreignthon-v*"
- "foreignthon-*-v*"
jobs: jobs:
publish: publish:
@@ -20,57 +21,67 @@ jobs:
- name: Install build tools - name: Install build tools
run: pip install build twine run: pip install build twine
- name: Build core package - name: Determine what to build
run: python -m build packages/foreignthon id: target
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "Full tag: $TAG"
- name: Build Spanish pack if [[ "$TAG" == "foreignthon-v"* ]]; then
run: python -m build packages/langs/es # Core package: foreignthon-v0.2.0
echo "type=core" >> $GITHUB_OUTPUT
echo "path=packages/foreignthon" >> $GITHUB_OUTPUT
echo "name=foreignthon" >> $GITHUB_OUTPUT
elif [[ "$TAG" == "foreignthon-"*"-v"* ]]; then
# Lang pack: foreignthon-es-v0.1.0 → lang code is "es"
LANG=$(echo $TAG | sed 's/foreignthon-\(.*\)-v.*/\1/')
echo "type=lang" >> $GITHUB_OUTPUT
echo "path=packages/langs/$LANG" >> $GITHUB_OUTPUT
echo "name=foreignthon-$LANG" >> $GITHUB_OUTPUT
fi
- name: Build package
run: python -m build ${{ steps.target.outputs.path }}
- name: Publish to PyPI - name: Publish to PyPI
env: env:
TWINE_USERNAME: __token__ TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: | run: twine upload --skip-existing ${{ steps.target.outputs.path }}/dist/*
twine upload --skip-existing packages/foreignthon/dist/*
twine upload --skip-existing packages/langs/es/dist/*
- name: Create Gitea release with assets - name: Create Gitea release with assets
env: env:
GIT_RELEASE_TOKEN: ${{ secrets.GIT_RELEASE_TOKEN }} GIT_RELEASE_TOKEN: ${{ secrets.GIT_RELEASE_TOKEN }}
run: | run: |
TAG=${GITHUB_REF#refs/tags/} TAG=${GITHUB_REF#refs/tags/}
echo "Tag: $TAG"
# Delete existing release if any
EXISTING=$(curl -s \ EXISTING=$(curl -s \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG") "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$TAG")
EXISTING_ID=$(echo $EXISTING | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "") EXISTING_ID=$(echo $EXISTING | python -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "")
if [ -n "$EXISTING_ID" ]; then if [ -n "$EXISTING_ID" ]; then
echo "Deleting existing release $EXISTING_ID"
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$EXISTING_ID" "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$EXISTING_ID"
fi fi
# Create fresh release
RELEASE=$(curl -s -X POST \ RELEASE=$(curl -s -X POST \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "{
\"tag_name\": \"$TAG\", \"tag_name\": \"$TAG\",
\"name\": \"$TAG\", \"name\": \"${{ steps.target.outputs.name }} ${TAG##*-v}\",
\"body\": \"Release $TAG\", \"body\": \"Release of ${{ steps.target.outputs.name }} ${TAG##*-v}\",
\"draft\": false, \"draft\": false,
\"prerelease\": false \"prerelease\": false
}" \ }" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases") "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases")
echo "Release response: $RELEASE"
RELEASE_ID=$(echo $RELEASE | python -c "import sys,json; print(json.load(sys.stdin)['id'])") RELEASE_ID=$(echo $RELEASE | python -c "import sys,json; print(json.load(sys.stdin)['id'])")
for FILE in packages/foreignthon/dist/* packages/langs/es/dist/*; do for FILE in ${{ steps.target.outputs.path }}/dist/*; do
echo "Uploading $FILE" echo "Uploading $FILE"
curl -s -X POST \ curl -s -X POST \
-H "Authorization: token $GIT_RELEASE_TOKEN" \ -H "Authorization: token $GIT_RELEASE_TOKEN" \

View File

@@ -1,51 +0,0 @@
# Contributing to ForeignThon
## Project structure
foreignthon/
├── packages/
│ ├── foreignthon/ # core engine + fpy CLI
│ │ ├── src/foreignthon/
│ │ │ ├── cli.py # fpy commands
│ │ │ ├── transpiler.py # tokenizer-based transpiler
│ │ │ ├── pack.py # language pack loader
│ │ │ └── errors.py # bilingual error hook
│ │ └── tests/
│ └── langs/
│ └── es/ # Spanish language pack
│ └── src/foreignthon_es/es.json
## Setting up
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
```
## Running tests
```bash
pytest packages/foreignthon/tests/ -v
```
## Adding a new language pack
1. Copy `packages/langs/es/` to `packages/langs/<code>/`
2. Rename `foreignthon_es``foreignthon_<code>` throughout
3. Fill in `<code>.json` following the same schema as `es.json`
4. Validate it: `fpy pack packages/langs/<code>/src/foreignthon_<code>/<code>.json`
5. Add tests if the language has tricky characters or edge cases
6. Open a PR or publish independently as `foreignthon-<code>` on PyPI
## Language pack schema
Every pack must have these top-level keys:
`meta`, `keywords`, `builtins`, `exceptions`, `error_messages`, `stdlib`
See `packages/langs/es/src/foreignthon_es/es.json` as the reference.
## Code style
```bash
ruff check packages/foreignthon/src
```

View File

@@ -1,7 +1,8 @@
DME.md << 'EOF'
# ForeignThon # ForeignThon
Write Python in any human language. ForeignThon transpiles `.es.py`, `.ta.py` (and more) files into standard Python — keywords, builtins, exceptions, all of it. Write Python in any human language.
ForeignThon transpiles `.es.py`, `.ta.py` (and more) into standard Python. Keywords, builtins, exceptions — all translated. Errors come back in your language too.
```python ```python
# hola.es.py # hola.es.py
@@ -19,7 +20,7 @@ fpy run hola.es.py
# Hola, mundo 2! # Hola, mundo 2!
``` ```
## Installation ## Install
```bash ```bash
pip install foreignthon pip install foreignthon
@@ -27,62 +28,13 @@ pip install foreignthon-es # Spanish
pip install foreignthon-ta # Tamil pip install foreignthon-ta # Tamil
``` ```
## Usage ## Docs
```bash - [Getting Started](docs/getting-started.md)
fpy run script.es.py # transpile and run - [Language Packs](docs/language-packs.md)
fpy compile script.es.py # output a .compiled.py file - [Postfix Syntax](docs/postfix-syntax.md)
fpy check script.es.py # validate without running - [Contributing](docs/contributing.md)
fpy pack mylang.json # validate a language pack - [Releasing](docs/releasing.md)
```
### Language override
```python
# foreignthon: es
# ^ overrides the file extension
```
Or via CLI flag:
```bash
fpy run script.py --lang es
```
## Errors
Errors are shown in your language first, English below:
[ES] ErrorDeDivisionCero: Error: división por cero
[EN] ZeroDivisionError: division by zero
## Language Packs
A language pack is a JSON file + a tiny Python wrapper published as `foreignthon-xx` on PyPI.
See `packages/langs/es/` for the reference implementation.
The JSON covers:
- **keywords** — `si → if`, `para → for`, `definir → def`
- **builtins** — `imprimir → print`, `rango → range`
- **exceptions** — `ErrorDeValor → ValueError`
- **error_messages** — bilingual error output
- **stdlib** — `matematicas → math`
Validate your pack before publishing:
```bash
fpy pack mylang.json
```
## Development
```bash
git clone <your-repo>
cd foreignthon
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pytest packages/foreignthon/tests/ -v
```
## License ## License

63
docs/contributing.md Normal file
View File

@@ -0,0 +1,63 @@
# Contributing
## Project structure
```
foreignthon/
├── packages/
│ ├── foreignthon/ # core engine + fpy CLI
│ │ ├── src/foreignthon/
│ │ │ ├── cli.py # fpy commands
│ │ │ ├── transpiler.py # tokenizer-based transpiler + @@ pre-pass
│ │ │ ├── pack.py # language pack loader + entry point discovery
│ │ │ └── errors.py # bilingual error hook
│ │ └── tests/
│ └── langs/
│ ├── es/ # foreignthon-es (Spanish)
│ └── ta/ # foreignthon-ta (Tamil)
├── docs/
└── .gitea/workflows/
├── ci.yml # runs tests + lint on every push
└── publish.yml # builds + publishes to PyPI on git tag
```
## How the transpiler works
1. **Pre-pass** — scans for `@@keyword` postfix syntax and rewrites lines to prefix form
2. **Tokenizer** — uses Python's `tokenize` module to swap `NAME` tokens. Strings and comments are never touched.
3. **Runner** — compiles with the original filename so tracebacks point to your `.es.py` file, not a temp file
## Setup
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pip install -e packages/langs/ta
```
## Running tests
```bash
pytest packages/foreignthon/tests/ -v
```
## Linting
```bash
ruff check packages/foreignthon/src
```
## Adding a language pack
See [language-packs.md](language-packs.md) for the full guide.
## CI
Every push to `main` runs tests and lint (non-blocking). Releases are triggered by pushing a git tag — see [releasing.md](releasing.md).
## Submitting changes
1. Fork the repo
2. Create a branch
3. Make your changes + add tests if relevant
4. Open a pull request against `main`

80
docs/getting-started.md Normal file
View File

@@ -0,0 +1,80 @@
# Getting Started
## Installation
```bash
pip install foreignthon
pip install foreignthon-es # add Spanish
pip install foreignthon-ta # add Tamil
```
For CLI use across projects, prefer pipx:
```bash
pipx install foreignthon
```
## Writing a file
Name your file `script.<lang>.py` — the extension tells ForeignThon which language pack to use.
```python
# script.es.py
definir sumar(a, b):
retornar a + b
para i en rango(5):
imprimir(sumar(i, 1))
```
## Running
```bash
fpy run script.es.py # transpile and run
fpy compile script.es.py # output a .compiled.py file
fpy check script.es.py # validate without running
```
## Overriding the language
Via shebang comment at the top of the file:
```python
# foreignthon: es
```
Or via CLI flag:
```bash
fpy run script.py --lang es
```
## Errors
Errors are shown in your language first, English below:
[ES] ErrorDeDivisionCero: Error: división por cero
[EN] ZeroDivisionError: division by zero
File "script.es.py", line 3
## Variable names
Variable names are optional — you can use English or your language freely:
```python
# both work fine in the same file
definir calculate(anchura, altura):
area = anchura * altura
retornar area
```
## Local dev setup
```bash
git clone <repo>
cd foreignthon
python -m venv .venv && source .venv/bin/activate
pip install -e "packages/foreignthon[dev]"
pip install -e packages/langs/es
pip install -e packages/langs/ta
pytest packages/foreignthon/tests/ -v
```

69
docs/language-packs.md Normal file
View File

@@ -0,0 +1,69 @@
# Language Packs
A language pack is a JSON file that maps foreign tokens to Python equivalents, published as `foreignthon-<code>` on PyPI.
## Available packs
| Package | Language | Install |
|---|---|---|
| `foreignthon-es` | Spanish | `pip install foreignthon-es` |
| `foreignthon-ta` | Tamil | `pip install foreignthon-ta` |
## JSON schema
Every pack must have these top-level keys:
```json
{
"meta": {
"name": "Spanish",
"native_name": "Español",
"code": "es",
"version": "0.1.0",
"authors": []
},
"keywords": { "si": "if", "para": "for", ... },
"builtins": { "imprimir": "print", "rango": "range", ... },
"exceptions": { "ErrorDeValor": "ValueError", ... },
"error_messages":{ "ValueError": "Error de valor", ... },
"stdlib": { "matematicas": "math", ... }
}
```
- **keywords** — Python reserved words (`if`, `for`, `def`, `class` …)
- **builtins** — built-in functions (`print`, `range`, `len` …)
- **exceptions** — built-in exception names (`ValueError`, `TypeError` …)
- **error_messages** — translations for bilingual error output
- **stdlib** — common standard library module names (`math`, `sys` …)
Third-party library names (numpy, pandas etc.) are out of scope.
## Creating a pack
1. Copy `packages/langs/es/` to `packages/langs/<code>/`
2. Rename every `foreignthon_es``foreignthon_<code>`
3. Fill in `<code>.json` following the schema above
4. Validate: `fpy pack packages/langs/<code>/src/foreignthon_<code>/<code>.json`
5. Install locally: `pip install -e packages/langs/<code>`
6. Test: `fpy run myscript.<code>.py`
## Publishing a pack
```bash
python -m build packages/langs/<code>
twine upload packages/langs/<code>/dist/*
```
Anyone can publish an independent `foreignthon-<code>` pack — you don't need to be a core maintainer.
## How discovery works
Packs register themselves via Python entry points:
```toml
# in the pack's pyproject.toml
[project.entry-points."foreignthon.langs"]
es = "foreignthon_es"
```
The core finds all installed packs automatically — no config needed.

49
docs/postfix-syntax.md Normal file
View File

@@ -0,0 +1,49 @@
# Postfix Syntax
Some languages (like Tamil) are grammatically SOV — the condition comes before the keyword, not after. ForeignThon supports this with the `@@` operator.
## How it works
```python
# Standard prefix (works in every language)
si x > 0:
imprimir(x)
# Postfix with @@
x > 0 @@si:
imprimir(x)
```
Both produce identical Python: `if x > 0:`. The `@@` means "take whatever is to my left, put the keyword first".
## Rules
- `@@` only rewrites the line it appears on — nothing else changes
- Indentation rules are identical to normal Python
- Prefix and postfix can be mixed freely in the same file
- Works for any keyword in any language pack
## Examples
```python
# if / else
x > 0 @@si:
imprimir(x)
sino:
pasar
# while
contador < 10 @@mientras:
contador += 1
# inside a function — indentation unchanged
definir comprobar(x):
x > 0 @@si:
imprimir("positivo")
sino:
imprimir("negativo")
```
## Why @@
`@@` is not valid Python syntax so it never conflicts with existing code. Single `@` is used for decorators and matrix multiplication, so it was ruled out.

View File

@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon" name = "foreignthon"
version = "0.1.0" version = "0.4.0"
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 = "MIT" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" }
] ]
keywords = ["python", "transpiler", "i18n", "localization", "language"] keywords = ["python", "transpiler", "i18n", "localization", "language"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",

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,11 +31,11 @@ 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")
transpiled = transpile_file(file)
detected_lang = lang or _lang_from_file(file) detected_lang = lang or _lang_from_file(file)
activate(detected_lang) activate(detected_lang)
transpiled = transpile_file(file)
if keep: if keep:
out_path = file.with_suffix("").with_suffix(".compiled.py") out_path = file.with_suffix("").with_suffix(".compiled.py")
out_path.write_text(transpiled, encoding="utf-8") out_path.write_text(transpiled, encoding="utf-8")
@@ -42,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."""
@@ -75,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."""
@@ -94,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

@@ -1,35 +1,89 @@
from __future__ import annotations from __future__ import annotations
import io import io
import re
import tokenize import tokenize
from pathlib import Path from pathlib import Path
from .pack import load_pack from .pack import load_pack
def _apply_postfix_syntax(source: str, mapping: dict) -> str:
if "@@" not in source:
return source
kw_pattern = "|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True))
postfix_re = re.compile(rf"(.+?)@@({kw_pattern})")
lines = source.splitlines(keepends=True)
result = []
for line in lines:
if "@@" not in line:
result.append(line)
continue
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
ending = "\n" if stripped.endswith("\n") else ""
content = stripped.rstrip("\n")
def _replace(m: re.Match) -> str:
return f"{m.group(2)} {m.group(1).strip()}"
result.append(indent + postfix_re.sub(_replace, content) + ending)
return "".join(result)
def _apply_postfix_output(source: str, en_to_foreign: dict, postfix_english: set) -> str:
"""
Post-pass for decompile: rewrite foreign keyword lines to @@ postfix.
postfix_english comes from the language pack's postfix_keywords list.
"""
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 transpile(source: str, lang_code: str) -> str: def transpile(source: str, lang_code: str) -> str:
"""
Transpile foreign-language Python source to standard Python.
Uses the tokenizer so strings and comments are never touched.
"""
pack = load_pack(lang_code) pack = load_pack(lang_code)
# Build a single flat lookup: foreign token -> English token
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
mapping.update(pack["keywords"]) mapping.update(pack["keywords"])
mapping.update(pack["builtins"]) mapping.update(pack["builtins"])
mapping.update(pack["exceptions"]) mapping.update(pack["exceptions"])
mapping.update(pack["stdlib"]) mapping.update(pack["stdlib"])
source = _apply_postfix_syntax(source, mapping)
tokens_in = tokenize.generate_tokens(io.StringIO(source).readline) tokens_in = tokenize.generate_tokens(io.StringIO(source).readline)
result: list[str] = [] result: list[str] = []
prev_end = (1, 0) prev_end = (1, 0)
for tok in tokens_in: for tok in tokens_in:
tok_type, tok_string, tok_start, tok_end, _ = tok tok_type, tok_string, tok_start, tok_end, _ = tok
# Preserve original whitespace/indentation between tokens
start_row, start_col = tok_start start_row, start_col = tok_start
end_row, end_col = prev_end end_row, end_col = prev_end
@@ -39,7 +93,6 @@ def transpile(source: str, lang_code: str) -> str:
result.append("\n" * (start_row - end_row)) result.append("\n" * (start_row - end_row))
result.append(" " * start_col) result.append(" " * start_col)
# Only swap NAME tokens — leaves strings, comments, ops untouched
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:
@@ -50,48 +103,73 @@ def transpile(source: str, lang_code: str) -> str:
return "".join(result) return "".join(result)
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
tokens_in = tokenize.generate_tokens(io.StringIO(source).readline)
result: list[str] = []
prev_end = (1, 0)
for tok in tokens_in:
tok_type, tok_string, tok_start, tok_end, _ = tok
start_row, start_col = tok_start
end_row, end_col = prev_end
if start_row == end_row:
result.append(" " * (start_col - end_col))
else:
result.append("\n" * (start_row - end_row))
result.append(" " * start_col)
if tok_type == tokenize.NAME and tok_string in en_to_foreign:
result.append(en_to_foreign[tok_string])
else:
result.append(tok_string)
prev_end = tok_end
output = "".join(result)
if postfix:
# Use pack-defined list, fallback to sensible defaults
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:
"""
Detect language from file extension (.es.py -> es),
read the file, and return transpiled Python source.
"""
lang_code = _detect_lang(path) lang_code = _detect_lang(path)
source = path.read_text(encoding="utf-8") source = path.read_text(encoding="utf-8")
# Allow shebang-style override: # foreignthon: fr
lang_code = _check_shebang(source, lang_code) lang_code = _check_shebang(source, lang_code)
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:
"""
Execute transpiled source while making tracebacks point
to the original .es.py file, not a temp file.
"""
import linecache import linecache
filename = str(original_path.resolve()) filename = str(original_path.resolve())
# Register original source lines so traceback displays them correctly
original_lines = original_path.read_text(encoding="utf-8").splitlines(keepends=True) original_lines = original_path.read_text(encoding="utf-8").splitlines(keepends=True)
linecache.cache[filename] = ( linecache.cache[filename] = (len(original_lines), None, original_lines, filename)
len(original_lines),
None,
original_lines,
filename,
)
# Compile with original filename — this is what sets it in the traceback
code = compile(transpiled, filename, "exec") code = compile(transpiled, filename, "exec")
glob = {"__file__": filename, "__name__": "__main__"} glob = {"__file__": filename, "__name__": "__main__"}
exec(code, glob) exec(code, glob)
def _detect_lang(path: Path) -> str: def _detect_lang(path: Path) -> str:
"""Extract lang code from extension, e.g. script.es.py -> es.""" suffixes = path.suffixes
suffixes = path.suffixes # e.g. ['.es', '.py']
if len(suffixes) >= 2 and suffixes[-1] == ".py": if len(suffixes) >= 2 and suffixes[-1] == ".py":
return suffixes[-2].lstrip(".") return suffixes[-2].lstrip(".")
raise ValueError( raise ValueError(
@@ -101,7 +179,6 @@ def _detect_lang(path: Path) -> str:
def _check_shebang(source: str, default: str) -> str: def _check_shebang(source: str, default: str) -> str:
"""Check first line for # foreignthon: <lang> override."""
first_line = source.splitlines()[0] if source else "" first_line = source.splitlines()[0] if source else ""
if first_line.startswith("# foreignthon:"): if first_line.startswith("# foreignthon:"):
return first_line.split(":", 1)[1].strip() return first_line.split(":", 1)[1].strip()

View File

@@ -5,12 +5,13 @@ from pathlib import Path
import pytest import pytest
from foreignthon.transpiler import transpile, _detect_lang, _check_shebang from foreignthon.transpiler import _check_shebang, _detect_lang, transpile
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# All tests use the real foreignthon-es pack — no mocks # 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")
@@ -19,27 +20,33 @@ def es(source: str) -> str:
# Keywords # Keywords
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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 imprimir(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_for_loop(): def test_for_loop():
out = es("para i en rango(10):\n imprimir(i)") out = es("para i en dist(10):\n imprimir(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_function_def(): def test_function_def():
out = es("definir saludar(nombre):\n retornar nombre") 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():
out = es("clase Animal:\n pasar") out = es("clase Animal:\n pasar")
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"
@@ -52,53 +59,95 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 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 def"')
assert '"si esto es para mientras definir"' in out assert '"si esto es para mientras def"' 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('imprimir(f"si {x} para")')
assert "si" in out # inside the string, untouched assert "si" in out # inside the string, untouched
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Output is always valid Python # Output is always 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" " imprimir(sumar(i, 1))\n"
) )
ast.parse(out) # raises if invalid ast.parse(out) # raises if invalid
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Language detection # Language detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_detect_lang_from_extension(): def test_detect_lang_from_extension():
assert _detect_lang(Path("script.es.py")) == "es" assert _detect_lang(Path("script.es.py")) == "es"
assert _detect_lang(Path("script.ta.py")) == "ta" assert _detect_lang(Path("script.ta.py")) == "ta"
def test_detect_lang_bad_extension(): 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 override
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_shebang_override(): def test_shebang_override():
assert _check_shebang("# foreignthon: fr\nsi x:\n pasar", "es") == "fr" assert _check_shebang("# foreignthon: fr\nsi x:\n pasar", "es") == "fr"
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 = (
"def 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,22 +4,17 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-es" name = "foreignthon-es"
version = "0.1.0" version = "0.3.0"
description = "Spanish language pack for ForeignThon." description = "Spanish language pack for ForeignThon."
license = { text = "MIT" } license = { text = "GPL v3" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" },
{ name = "Cody Trainer" },
] ]
keywords = ["foreignthon", "spanish", "español"] keywords = ["foreignthon", "spanish", "español"]
classifiers = [
"Development Status :: 3 - Alpha", dependencies = ["foreignthon>=0.4.0"]
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"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

@@ -0,0 +1 @@
# foreignthon-ta

View File

@@ -4,23 +4,15 @@ build-backend = "hatchling.build"
[project] [project]
name = "foreignthon-ta" name = "foreignthon-ta"
version = "0.1.0" version = "0.2.1"
description = "Tamil language pack for ForeignThon." description = "Tamil language pack for ForeignThon."
readme = "README.md" license = { text = "GPL v3" }
license = { text = "MIT" }
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [
{ name = "Your Name", email = "you@example.com" } { name = "Keshav Anand", email = "keshavanand.dev@gmail.com" }
] ]
keywords = ["foreignthon", "tamil", "தமிழ்"] keywords = ["foreignthon", "tamil", "தமிழ்"]
classifiers = [ dependencies = ["foreignthon>=0.4.0"]
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"foreignthon>=0.1.0",
]
[project.entry-points."foreignthon.langs"] [project.entry-points."foreignthon.langs"]
ta = "foreignthon_ta" ta = "foreignthon_ta"

View File

@@ -0,0 +1,4 @@
from importlib.resources import files
def get_pack_path():
return files(__name__) / "ta.json"

View File

@@ -0,0 +1,141 @@
{
"meta": {
"name": "Tamil",
"native_name": "தமிழ்",
"code": "ta",
"version": "0.1.0",
"authors": []
},
"keywords": {
"ஆனால்": "if",
"மற்றபடி": "else",
"இல்லைஆனால்": "elif",
"ஆக": "for",
"வரை": "while",
"நிரல்பாகம்": "def",
"கோப்பு": "class",
"பின்கோடு": "return",
"நிறுத்து": "break",
"தொடர்": "continue",
"கடந்துசெல்": "pass",
"முயற்சி": "try",
"தவிர": "except",
"கடைசியில்": "finally",
"எழுப்பு": "raise",
"உடன்": "with",
"இறக்கு": "import",
"இருந்து": "from",
"என": "as",
"உள்ளே": "in",
"ஆகும்": "is",
"மற்றும்": "and",
"அல்லது": "or",
"இல்லை": "not",
"நீக்கு": "del",
"உலகளாவிய": "global",
"உள்ளூர்சாரா": "nonlocal",
"உறுதிப்படுத்து": "assert",
"வழங்கு": "yield",
"காத்திரு": "await",
"ஒத்திசைவற்ற": "async",
"சிறுசார்பு": "lambda",
"உண்மை": "True",
"பொய்": "False",
"ஒன்றுமில்லை": "None"
},
"builtins": {
"பதிப்பி": "print",
"உள்ளீடு": "input",
"நீளம்": "len",
"வரம்பு": "range",
"வகை": "type",
"முழுஎண்": "int",
"தசமஎண்": "float",
"சரம்": "str",
"பட்டியல்": "list",
"அகராதி": "dict",
"கணம்": "set",
"தொகுப்பு": "tuple",
"உண்மைவகை": "bool",
"திற": "open",
"எண்ணிட": "enumerate",
"மேப்": "map",
"வடிகட்டு": "filter",
"வரிசைப்படுத்து": "sorted",
"கூட்டுத்தொகை": "sum",
"குறைந்தபட்சம்": "min",
"அதிகபட்சம்": "max",
"தனிமதிப்பு": "abs",
"சுற்று": "round",
"அனைத்தும்": "all",
"ஏதாவது": "any",
"அடுத்தது": "next",
"எண்": "id",
"எழுத்து": "chr",
"தலைகீழ்": "reversed"
},
"exceptions": {
"விதிவிலக்கு": "Exception",
"அடிப்படைவிதிவிலக்கு": "BaseException",
"மதிப்புபிழை": "ValueError",
"வகைபிழை": "TypeError",
"திறவுகோல்பிழை": "KeyError",
"குறியீட்டுபிழை": "IndexError",
"பண்புபிழை": "AttributeError",
"பெயர்பிழை": "NameError",
"இறக்குமதிபிழை": "ImportError",
"கோப்புகிடைக்கவில்லை": "FileNotFoundError",
"இயக்கநேரபிழை": "RuntimeError",
"பூஜ்யபிரிவுபிழை": "ZeroDivisionError",
"தொடரியல்பிழை": "SyntaxError",
"உறுதிப்பாட்டுபிழை": "AssertionError",
"நினைவகபிழை": "MemoryError",
"மிகைபிழை": "OverflowError",
"சுழல்பிழை": "RecursionError",
"அனுமதிபிழை": "PermissionError",
"நேரமுடிந்தது": "TimeoutError",
"கணினிவெளியேறு": "SystemExit",
"விசைகுறுக்கீடு": "KeyboardInterrupt"
},
"error_messages": {
"SyntaxError": "தொடரியல் பிழை",
"ValueError": "மதிப்பு பிழை",
"TypeError": "வகை பிழை",
"KeyError": "திறவுகோல் பிழை",
"IndexError": "குறியீட்டு பிழை",
"AttributeError": "பண்பு பிழை",
"NameError": "பெயர் பிழை",
"ImportError": "இறக்குமதி பிழை",
"FileNotFoundError": "கோப்பு கிடைக்கவில்லை",
"ZeroDivisionError": "பூஜ்யத்தால் வகுக்க முடியாது",
"RecursionError": "சுழல் பிழை",
"RuntimeError": "இயக்க நேர பிழை",
"MemoryError": "நினைவக பிழை",
"OverflowError": "மிகை பிழை",
"AssertionError": "உறுதிப்பாட்டு பிழை",
"PermissionError": "அனுமதி பிழை",
"TimeoutError": "நேரம் முடிந்தது",
"KeyboardInterrupt": "விசை குறுக்கீடு"
},
"stdlib": {
"கணிதம்": "math",
"கணினி": "sys",
"தேதிநேரம்": "datetime",
"நேரம்": "time",
"சீரற்ற": "random",
"தொகுப்புகள்": "collections",
"பாதை": "pathlib",
"வழக்கமொழி": "re"
},
"postfix_keywords": [
"if",
"elif",
"while",
"class",
"with",
"try",
"except",
"finally",
"from"
]
}