Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

소개

English

Renk는 Rust의 들여쓰기 기반 문법으로, Rust’s Ugly Syntax에서 영감을 받았다. renk는 .rk 파일을 Rust로 트랜스파일하고, Rust를 다시 Renk로 변환한다.

#[derive(Debug, Clone)]
pub struct User:
    pub id: u64
    pub name: String

impl User:
    pub fn new(id: u64, name: String) -> Self = Self{ id, name }
    pub fn display_name(&self) -> &str = &self.name

pub fn main():
    let user = User.new(1, "Ada".to_string())
    println!("{user.display_name()}")

생성된 Rust:

#[derive(Debug, Clone)]
pub struct User {
    pub id: u64,
    pub name: String,
}
impl User {
    pub fn new(id: u64, name: String) -> Self {
        Self{ id, name }
    }
    pub fn display_name(&self) -> &str {
        &self.name
    }
}
pub fn main() {
    let user = User::new(1, "Ada".to_string());
    println!("{}", user.display_name());
}

Renk는 문법만 바꾼다. 타입, 빌림, 매크로, 크레이트는 Rust 그대로이고, 트랜스파일러가 다시 쓰지 않는 토큰은 Rust로 그대로 통과한다. Rust의 { ... } 블록, :: 경로, ::<T> 터보피시는 언제나 쓸 수 있다.

시작하기

설치

체크아웃한 저장소에서 CLI와 언어 서버를 설치한다:

cargo install --path crates/renk --bin renk --bin renk-lsp

스크립트를 쓰려면 nightly 툴체인도 필요하다(rustup toolchain install nightly). 언어 서버는 PATH에 rust-analyzer가 있으면 그것을 쓴다.

첫 프로그램

다음을 hello.rk로 저장한다:

pub fn sum_until(values: &[i32], limit: usize) -> i32:
    let mut index = 0
    let mut total = 0
    while index < limit:
        total += values[index]
        index += 1
    if total < 0 then 0 else total

pub fn main():
    println!("{sum_until(&[1, 2, 3], 2)}")

renk transpile hello.rk -o hello.rs && rustc --edition 2024 hello.rs && ./hello를 실행하면 3이 출력된다.

examples 디렉터리에 예제 프로그램이 더 있다.

명령

renk transpile input.rk -o output.rs           # Renk to Rust
renk transpile input.rk --source-map out.json  # also write a JSON source map
cat input.rk | renk                            # stdin to stdout
renk from-rust input.rs -o output.rk           # Rust to Renk
renk fmt input.rk -o input.rk                 # format
renk run                                        # transpile a crate, then cargo run
renk cargo test                                 # any cargo command
renk script tool.rk arg                        # run a single-file script
renk add tool.rk regex                         # add a script dependency
renk-lsp                                        # language server on stdio

-o가 없으면 stdout으로 출력한다. 입력 없이 stdin이 터미널인 상태로 실행하면 renk는 도움말을 출력한다.

문법

Renk는 네 군데에서 Rust와 다르다:

  • 블록은 :로 열고 들여쓰기로 닫는다. 문장은 줄 끝에서 끝난다.
  • 표현식에 if c then a else b가 있고, if, match, 클로저 블록을 값 자리에 쓸 수 있다.
  • 경로와 제네릭은 Rust의 ::와 <T> 대신 .와 [T]를 쓴다.
  • 포맷 문자열에 {user.name()} 같은 인라인 표현식을 쓸 수 있다.

나머지는 Rust와 같다. Rust의 { ... } 블록, :: 경로, ::<T> 터보피시는 언제나 쓸 수 있고 쓴 그대로 유지된다.

블록

들여쓰기

:로 끝나는 줄은 그 뒤에 오는 더 깊이 들여쓴 줄들을 담는 블록을 연다. struct, enum, union, trait, impl, mod, macro_rules!, fn, if, else, match, loop, while, for, unsafe, async에 쓸 수 있다. 중괄호, 필드와 arm 뒤의 ,, 문장 뒤의 ;는 Renk가 써 넣는다.

pub enum Shape:
    Circle(f64)
    Square(f64)

pub fn area(shape: &Shape) -> f64:
    match shape:
        Shape.Circle(radius) => 3.14 * radius * radius
        Shape.Square(side) => side * side

한 블록의 줄들이 같은 칸 수를 쓰기만 하면 공백 몇 칸이든 한 단계가 된다. 탭은 오류다.

unsafe:, async:, loop:는 문장이나 match arm 본문에서만 블록을 연다. let의 값이나 함수 인자로 쓸 때는 let task = async move { ... }처럼 Rust 중괄호가 필요하다. extern 블록도 중괄호가 필요하다.

: 블록은 fn main() {이나 fn main() {: 뒤에 들여쓴 줄을 두는 식으로 Rust의 여는 중괄호를 남겨 둘 수 있다. 닫는 }는 필요 없다. Rust { 블록은 어디서나 쓸 수 있으므로, 예를 들어 한 줄짜리 본문을 그대로 둘 수 있다:

pub fn main():
    let value = { 1 + 2 }
    if value > 2 { println!("big") }

세미콜론

문장은 줄 끝에서 끝난다. Renk는 시그니처를 보고 ;를 추론한다. 값을 반환하는 함수는 마지막 표현식을 값으로 남기고, unit 함수는 마지막 표현식 뒤에 ;를 붙인다. 추론이 남기는 값을 버리고 싶을 때만 ;를 직접 쓴다.

#[cfg(...)]로 고르는 꼬리 표현식은 각각 값이다. Rust는 문장과 블록에만 속성을 허용하므로, 블록이 아닌 꼬리는 블록이 된다:

pub fn separator() -> char:
    #[cfg(unix)]
    '/'
    #[cfg(not(unix))]
    '\\'

이 코드는 #[cfg(unix)] / { '/' } / #[cfg(not(unix))] / { '\\' }가 된다.

함수 축약형

fn name(...) -> T = expr는 본문이 표현식 하나인 함수다. = match n: 뒤에 arm을 들여써 두는 것처럼, 그 표현식이 블록을 열 수도 있다.

pub fn twice(value: i32) -> i32 = value * 2

pub fn name(n: i32) -> &'static str = match n:
    0 => "zero"
    _ => "many"

여러 줄 헤더

where 절이나 긴 조건처럼 더 깊이 들여쓴 줄로 이어지는 헤더는 마지막 줄을 :로 끝낸다. 본문은 헤더 첫 줄보다 한 단계 깊게 들여쓴다:

pub fn show[T](value: T) -> String
where
    T: std.fmt.Debug:
    format!("{value:?}")

pub fn check(a: bool, b: bool) -> bool:
    if a
        && b:
        return true
    false

Self::Item: / Clone + Default:처럼 rustfmt가 bound를 더 깊은 줄로 이어 쓰면, 헤더는 그 깊은 줄에서 끝난다. 본문이 없는 항목의 where 절은 마지막 bound에서 끝난다.

그룹

(나 [로 끝나는 줄, {로 끝나는 구조체 리터럴이나 use 트리, 닫히지 않은 문자열, 끝에 붙은 =는 그룹이 닫힐 때까지 이어진다. 그룹 안은 Rust 문법이다. 그 뒤 같은 들여쓰기의 .method()나 ? 줄은 표현식을 이어 간다.

pub fn total(values: &[i32]) -> i32:
    values
        .iter()
        .map(|value| value * 2)
        .sum()

표현식

if 표현식

if condition then a else b는 한 줄짜리 if 표현식이다:

pub fn clamp(total: i32) -> i32 = if total < 0 then 0 else total

블록을 여는 if는 let x = if c:, x += if c:, return if c:, max(if c:, .map(|x| if c: 처럼 값 자리에 올 수 있고, else if d:와 else: 줄은 if와 같은 들여쓰기에 둔다. match도 let x = match y:, .filter(|v| match v:, [match x:처럼 쓸 수 있고, f(if c { 뒤의 else: 가지도 마찬가지다.

pub fn sign(value: i32) -> i32:
    let sign = if value > 0:
        1
    else if value < 0:
        -1
    else:
        0
    sign

머리와 같은 들여쓰기의 } 줄은 연산자로 값을 이어 간다:

pub fn bucket(value: i32) -> i32:
    let base = match value:
        0 => 1
        _ => 2
    } * 10
    base

let ... else

pub fn first(values: &[i32]) -> i32:
    let [first, ..] = values else:
        return 0
    *first

클로저

클로저 머리는 let f = |n: u8| -> String:, move ||:, xs.sort_by(|a, b|:처럼 블록을 열 수 있다. 머리가 (나 [를 열어 둔 블록은 머리와 같은 들여쓰기에서 그것을 닫는 줄로 끝나고, 그 줄의 나머지는 표현식을 이어 간다:

pub fn sorted(mut values: Vec[i32]) -> Vec[i32]:
    values.sort_by(|a, b|:
        b.cmp(a)
    )
    values

pub fn total(values: &[i32]) -> i32:
    values.iter().map(|value|:
        value * 2
    ).sum()

{ 머리도 같은 방식으로 })로 닫는다. { 블록, 끝에 붙은 match 머리, 머리가 {로 끝나는 체인의 마지막 else 뒤에서는 })?나 }).method() 같은 닫는 줄이 뒤의 접미사를 유지한다. 한 줄로 쓴 마지막 else는 else { floor })처럼 그룹을 닫는 것으로 체인을 끝낸다:

pub fn raised(values: &[i32], floor: i32) -> i32:
    values.iter().map(|value| if *value > floor:
        *value
    else { floor }).sum()

닫는 줄에서 다음 블록 인자를 열 수 있다:

pub fn size(value: Option[usize]) -> usize:
    value.map_or_else(||:
        0
    , |n|:
        n * 2
    )

Match arm

블록을 여는 match arm 본문은 Some(x) => match x:, Ok(v) => unsafe:, None => Ok(unsafe:처럼 그 뒤에 들여쓴 줄들을 차지한다.

pub fn describe(value: Option[i32]) -> &'static str:
    match value:
        Some(x) => match x:
            0 => "zero"
            _ => "some"
        None => "none"

구조체 리터럴

구조체 리터럴은 Rust 문법을 그대로 쓴다: Self{ id, name }.

경로와 제네릭

점 경로

a가 self이거나, SCREAMING_CASE 상수이거나, 그 주변에서 let, 매개변수(const 제네릭 포함), 패턴으로 묶인 값이면 a.b는 필드나 메서드다. 그 밖의 a는 타입, 모듈, 크레이트의 이름이므로 a.b는 경로다.

RenkRust
std.io.Readstd::io::Read
Foo.new()Foo::new()
env.args()env::args()
let user = ... 뒤의 user.name()user.name()

바인딩은 let부터 그 블록 끝까지, if let, while let, for 블록 안에서, 그 match arm 안에서, 그리고 if let Some(v) = x then v.len() else 0처럼 자기가 있는 줄 전체에서 값이다.

명시적인 :: 경로 뒤에서는 CamelCase 마지막 세그먼트가 그다음 .를 경로로 만든다. regex::Regex.new(s)는 regex::Regex::new(s)이고, regex.Regex.new(s)도 같다. Config::DEFAULT.len()은 값 receiver를 유지한다.

let env = load()가 있으면 그 뒤의 env.len()은 메서드 호출이다. 이 규칙이 놓치는 값 receiver는 (env).len()처럼 괄호로 지킨다. 유니코드 식별자는 목록.변환[Vec[i32]]()처럼 Rust의 XID 규칙을 따른다.

제네릭

제네릭 매개변수와 인자는 [T]를 쓴다:

pub struct Boxed[T]:
    pub value: T

impl[T] Boxed[T]:
    pub fn new(value: T) -> Self = Self{ value }

pub fn parse(raw: &str) -> Result[Boxed[i32], std.num.ParseIntError]:
    let value = raw.parse[i32]()?
    Ok(Boxed.new(value))
RenkRust
values.iter().collect[Vec[i32]]()values.iter().collect::<Vec<i32>>()
size_of[T]()size_of::<T>()
serde_json::from_str[T](s)serde_json::from_str::<T>(s)
Vec[String].new()Vec::<String>::new()
values[index]values[index]
TABLE[i].len()TABLE[i].len()

name[...](, 그리고 ::나 .name 앞의 CamelCase Name[...]는 제네릭 적용이다. 그 밖의 모든 [...]는 인덱싱이며, 내용을 보고 추론하지 않는다. 인덱싱한 뒤 호출하려면 Rust처럼 (handlers[i])()로 괄호를 친다.

그 밖의 제네릭 적용은 .map(str::parse::<i32>)처럼 Rust 문법을 쓴다.

문장 안의 타입 자리, 즉 let, static, const의 타입 표기와 클로저 매개변수·반환 타입은 let found: Option[i32] = None처럼 [T]를 쓴다.

탈출구

  • ::와 ::<...>는 경로나 제네릭 적용을 강제한다.
  • 괄호는 값 receiver를 강제하고, Rust 출력에서는 사라진다: (Unit).method() -> Unit.method(), (Ordering::Less).reverse() -> Ordering::Less.reverse().

매크로와 포맷 문자열

포맷 문자열

std 포맷 매크로에는 인라인 표현식을 쓸 수 있다. {expr}와 {expr:spec}은 위치 인자가 된다. 자리표시자 안의 문자열은 "{tags.join(\", \")}"처럼 리터럴의 이스케이프로 쓰고, raw 리터럴에서는 r#"{v.join("-")}"#처럼 따옴표를 그대로 쓴다.

pub fn report(name: &str, values: &[i32], tags: &[&str]):
    println!("{name} has {values.len()} values")
    println!("{tags.join(\", \")}")
    assert!(!values.is_empty(), "{values.len():>4}")

이 코드는 다음이 된다.

#![allow(unused)]
fn main() {
pub fn report(name: &str, values: &[i32], tags: &[&str]) {
    println!("{} has {} values", name, values.len());
    println!("{}", tags.join(", "));
    assert!(!values.is_empty(), "{:>4}", values.len());
}
}

자리표시자가 모두 단순 식별자, 위치, {}뿐인 문자열은 Rust 그대로 둔다. 인라인 표현식이 있는 문자열에 명시적 인자가 없으면 단순 식별자도 인자가 되어, 인자가 문자열의 순서를 따른다.

적용되는 매크로는 format!, print!, println!, eprint!, eprintln!, write!, writeln!, panic!, format_args!, todo!, unreachable!, unimplemented!이다. 이 매크로들과 assert!, assert_eq!, assert_ne!, 이들의 debug_ 형태, dbg!, vec!, matches!의 인자는 Renk 표현식이다: vec![Foo.new(); 3]은 vec![Foo::new(); 3]이다.

log::info! 같은 다른 매크로는 #[...], #![...] 속성의 내용과 마찬가지로 해석하지 않는 Rust 토큰이다.

view!

Leptos 등의 view! 본문에서 여러 줄에 걸친 { 블록은 Renk 문장을 담으므로 ;가 필요 없다. 태그, 속성, {count} 같은 한 줄 블록은 Rust 토큰으로 남는다.

view! {
    <button on:click=move |_| {
        set_count.update(|n| *n += 1)
        log::info!("clicked")
    }>
        {count}
    </button>
}

는 다음이 된다.

#![allow(unused)]
fn main() {
view! {
    <button on:click=move |_| {
        set_count.update(|n| *n += 1);
        log::info!("clicked")
    }>
        {count}
    </button>
}
}

macro_rules!

arm은 들여쓰기로 구분한다. 패턴과 본문은 Rust 토큰이며 쓴 그대로 유지된다.

macro_rules! make_message:
    ($name:expr) => format!("hello {}", $name)
    ($name:expr, $greeting:expr) =>:
        format!("{} {}", $greeting, $name)

여러 줄짜리 pattern =>: arm은 들여쓴 본문 줄을 모두 유지한다.

스크립트

renk script [--cargo] FILE.rk [ARGS...], 줄여서 renk FILE.rk [ARGS...]는 단일 파일 스크립트를 트랜스파일하고, cargo +nightly -Zscript로 빌드한 뒤 ARGS를 넘겨 실행한다. nightly 툴체인이 필요하다.

#!/usr/bin/env -S renk script
---
[dependencies]
regex = "1"
---

fn main():
    println!("{}", regex.Regex.new("a+").unwrap().is_match("caab"))

이 #! 줄이 있으면 chmod +x tool.rk 후 ./tool.rk ARGS로 스크립트를 실행할 수 있다.

renk transpile tool.rk는 renk script가 빌드하는 Rust를 출력한다. frontmatter는 유지되고 최상위 줄은 fn main으로 감싸진다. 스크립트에는 --source-map을 쓸 수 없다. #! 줄이나 --- frontmatter로 시작하는 파일이 스크립트다.

의존성

의존성은 cargo-script --- frontmatter에 적는다. frontmatter는 Cargo 매니페스트다. edition을 지정하지 않은 스크립트는 package.edition = "2024"를 받는다.

renk add FILE.rk [CARGO_ADD_ARGS...]는 frontmatter에 cargo add를 실행하고 결과를 다시 써 넣는다. frontmatter가 없으면 #! 줄 뒤에 만들고, 스크립트의 나머지는 건드리지 않는다. 상대 --path 의존성은 스크립트가 있는 디렉터리를 기준으로 해석한다.

renk add tool.rk regex
renk add tool.rk serde --features derive

frontmatter에 없는 크레이트 때문에 빌드가 실패하면 오류 메시지가 add it with `renk add tool.rk regex` 처럼 해당 명령을 알려 준다.

최상위 문장

최상위 fn main이 없는 스크립트는 최상위 줄들을 fn main() -> Result<(), Box<dyn std::error::Error>> 안에서 차례로 실행하므로 ?를 쓸 수 있다:

#!/usr/bin/env -S renk script

let args = std.env.args().skip(1).collect[Vec[String]]()
let n: i32 = args.first().map_or("2", |s| s.as_str()).parse()?
println!("{n * 2}")

anyhow, eyre, color-eyre에 의존하는 스크립트는 대신 그 크레이트의 Result<()>를 반환하므로 bail!을 쓸 수 있다. 최상위 return은 return Ok(())로 써야 한다.

환경

  • RENK_SCRIPT에는 스크립트의 절대 경로가 들어 있다.
  • CARGO_MANIFEST_DIR와 CARGO_PKG_NAME은 cargo run과 같은 방식으로 설정된다.
  • renk는 스크립트의 종료 코드로 끝나고, 스크립트가 시그널로 죽으면 128에 시그널 번호를 더한 코드로 끝난다.

빌드와 캐시

  • 생성된 Rust는 $XDG_CACHE_HOME/renk/scripts/(XDG_CACHE_HOME이 없으면 ~/.cache/renk/scripts/)에 저장되며, 내용이 바뀔 때만 다시 쓴다.
  • 실행할 때는 캐시된 바이너리를 cargo 없이 실행하고, 생성된 Rust가 바뀔 때만 다시 빌드한다. renk script --cargo FILE.rk는 매번 cargo를 거쳐 빌드하므로 의존성과 툴체인 업데이트를 반영한다.
  • 모든 스크립트는 공유 디렉터리 $XDG_CACHE_HOME/renk/build/에서 빌드하므로 각 의존성은 한 번만 컴파일된다. 동시에 빌드하는 스크립트는 서로를 기다린다. 이 디렉터리를 지우면 다시 빌드해야 하고 스크립트들의 Cargo.lock도 사라지므로, 다음 빌드는 더 새로운 의존성 버전을 고를 수 있다.
  • renk는 스크립트의 패키지 이름을 <stem>-<hash>로 정하며, 이 이름은 CARGO_PKG_NAME과 바이너리 이름에 나타난다. package.name을 직접 정한 스크립트는 그 이름을 유지하고, 사용자가 CARGO_TARGET_DIR을 지정했더라도 캐시된 Rust 옆의 별도 target 디렉터리에서 빌드한다.

진단

  • 컴파일러 경고는 스크립트를 컴파일하는 실행에서만 출력된다.
  • 진단은 스크립트의 줄과 열을 가리키고 스크립트 소스를 인용한다. 닫는 중괄호처럼 renk가 만들어 낸 줄의 span은 그 줄이 닫는 스크립트 줄의 끝을 가리킨다.
  • rustc가 제안하는 수정은 Rust 코드이므로 뺀다.
  • a.b에서 이름을 찾지 못하면 renk가 점을 어떻게 읽었는지 알려 주는 help 줄이 붙는다. 한계를 참고한다.
  • panic 메시지와 backtrace는 스크립트의 줄과 열을 가리킨다.
  • 패키지 이름을 renk가 정했으면 cargo의 실패 요약은 could not compile `tool.rk` 처럼 스크립트 이름을 쓴다.

Cargo 프로젝트

Cargo가 .rs 파일을 기대하는 자리에 .rk 파일을 둔다. 다음 명령은 src, examples, tests, benches 아래의 모든 .rk 파일과 build.rk를 각각 옆의 .rs 파일로 트랜스파일한 뒤 같은 Cargo 명령을 실행한다:

renk build
renk check
renk run
renk test
renk clippy
renk doc
renk bench
renk cargo ARGS...  # any other cargo command, as cargo ARGS...

생성된 파일은 // @generated by renk; do not edit.로 시작하며, 내용이 바뀔 때만 다시 쓴다. 이 헤더가 없는 .rs 파일은 덮어쓰지 않고 오류로 멈춘다.

src/main.rk:

fn main():
    println!("{double(21)}")

fn double(n: i32) -> i32 = n * 2

#[cfg(test)]
mod tests:
    #[test]
    fn doubles():
        assert_eq!(super.double(2), 4)

renk run은 42를 출력하고, renk test는 doubles를 실행한다. 본문 없는 mod name은 Rust의 mod name;처럼 모듈 파일을 선언한다.

진단

  • 생성된 파일에 대한 컴파일러 진단은 .rk의 줄과 열을 가리키고 .rk 소스를 인용한다. 다른 파일에 대한 진단은 rustc가 렌더링한 그대로 출력한다.
  • rustc가 제안하는 수정은 Rust 코드이므로 .rk span에서는 뺀다.
  • --message-format=short는 src/main.rk:LINE:COLUMN: error: ... 줄을 출력한다. --message-format=json...은 span, 인용한 줄, rendered 텍스트를 .rk 파일로 옮긴 cargo의 JSON 메시지를 출력한다.
  • renk run, renk test, renk bench의 panic 메시지와 backtrace는 .rk 위치를 보여 준다. 출력이 터미널인 프로그램은 계속 터미널에 쓴다.
  • a.b에서 이름을 찾지 못하면 renk가 점을 어떻게 읽었는지 알려 주는 help 줄이 붙는다. 한계를 참고한다.

에디터 설정

renk-lsp는 .rk 파일용 stdio Language Server Protocol 서버다. cargo install --path crates/renk --bin renk-lsp로 설치한다. 제공하는 기능은 다음과 같다:

  • Renk 문법 오류 진단.
  • 문서 심볼.
  • PATH에 rust-analyzer가 있으면, 생성된 Rust에 대한 rust-analyzer의 진단(저장할 때 도는 cargo check의 타입 오류 포함), hover, 정의로 이동, 자동 완성, 참조 찾기, 이름 바꾸기, 코드 액션, 인레이 힌트.

위치는 .rk 파일 기준으로 되돌려 매핑한다. 생성된 Rust와 다른 Renk 텍스트를 바꾸게 되는 이름 바꾸기는 실패하고, 편집을 .rk 줄로 옮길 수 없는 코드 액션은 빠진다. a.b 점에서 난 이름 해석 오류에는 renk check와 같은 힌트가 붙는다(한계 참고).

#! 줄이나 frontmatter가 있거나 Cargo 패키지 밖에 있는 .rk 파일은 스크립트로 보고 renk script와 같은 방식으로 트랜스파일한다. 스크립트는 $XDG_CACHE_HOME/renk/lsp/에 생성된 cargo-script 파일을 대상으로 자기 전용 rust-analyzer를 띄우므로 frontmatter 의존성이 해석된다.

Zed

editors/zed의 확장은 Renk 언어를 등록하고 renk-lsp를 실행하며 Tree-sitter로 하이라이트한다. Zed 확장 페이지의 Install Dev Extension에서 editors/zed를 선택해 설치한다.

Neovim

Neovim 0.11 이상:

vim.filetype.add({ extension = { rk = "renk" } })
vim.lsp.config("renk", {
  cmd = { "renk-lsp" },
  filetypes = { "renk" },
  root_markers = { "Cargo.toml", ".git" },
})
vim.lsp.enable("renk")

다른 에디터

.rk로 끝나는 파일에 대해 인자 없는 renk-lsp 명령을 stdio 언어 서버로 설정한다.

Rust에서 변환하기

renk from-rust는 Rust를 Renk로 변환한다:

renk from-rust src/lib.rs -o src/lib.rk
renk from-rust src/lib.rs -o src/lib.rk --aggressive

출력은 다시 트랜스파일했을 때 같은 Rust 토큰이 나오는 곳에서만 점 경로, [T] 제네릭, fn f() -> T = expr, 추론된 ;, if c then a else b를 쓴다. 각각 자리표시자 하나를 순서대로 채우는 포맷 인자는 인라인으로 바뀐다: format!("{}", cli.url)은 format!("{cli.url}")이 된다. :: 경로는 같은 의미로 읽히는 곳에서 점 경로가 된다: std::env::args()는 std.env.args()가 되지만 Config::DEFAULT.len()은 ::를 유지한다. 터보피시 호출은 s.parse[i32]()처럼 name[...](가 되고, CamelCase 경로의 터보피시는 Vec[String].new()처럼 Name[...]가 된다. 그 밖의 터보피시는 Rust 그대로다. 인덱싱 후 호출하는 receiver와 경로 값 receiver는 (handlers[i])(), (std.cmp.Ordering.Less).reverse()처럼 괄호로 감싼다.

--aggressive는 Rust 토큰은 같지만 줄 배치가 달라지는 변환, 예를 들어 여러 줄짜리 if/else 값 체인도 허용한다.

주석, 문자열 리터럴, 매크로 본문은 쓴 그대로 유지된다. match scrutinee를 담은 { 블록처럼 Renk에 들여쓰기 형태가 없는 것은 Rust 문법으로 남는다.

왕복 테스트

저장소는 Rust 파일 코퍼스와 버전을 고정한 실제 크레이트로 Rust -> Renk -> Rust 왕복을 테스트한다. 실제 크레이트마다 왕복한 크레이트가 컴파일되고, 모든 주석을 유지하고, 소스 위치, 지역 변수와 basic block 번호, unit 임시값을 정규화한 뒤 원본과 같은 MIR을 만들어야 한다. MIR은 원본이 그렇게 빌드되는 경우에 한해 기본 feature, 모든 feature, 단위 테스트 각각으로 비교한다.

cargo test -p renk --test real_crates -- --ignored --nocapture

이 테스트는 crates.io에서 크레이트를 내려받으며, --emit=mir에 nightly 툴체인이 필요하다.

도구

포매터

renk fmt는 rustfmt가 Rust를 배치하듯 파일의 들여쓰기, 줄바꿈, 공백을 정리한다. 생성한 Rust에 rustfmt --edition 2024를 돌리므로 rustfmt(rustup component add rustfmt)가 필요하고, 작업 디렉터리의 rustfmt.toml을 따른다. 주석, 빈 줄, 문자열 내용은 그대로 둔다. 스크립트의 #! 줄과 frontmatter도 그대로다. Rust를 바꾸지 않고는 정리할 수 없는 항목은 줄 끝 공백만 지우고 원래 줄을 유지한다.

renk fmt src/main.rk -o src/main.rk
renk fmt --check src/*.rk    # 정리되지 않은 파일이 있으면 diff를 출력하고 1로 끝난다

소스 맵

renk transpile FILE.rk --source-map MAP.json은 Renk 문법 노드의 JSON 트리도 쓴다. 각 노드에는 소스의 줄, 열, 텍스트와 그 노드가 된 생성 Rust의 줄 범위가 들어 있다.

웹 러너

podman compose up --build는 http://localhost:8080에서 웹 에디터를 띄운다. 트랜스파일은 브라우저에서 하고, 스니펫의 컴파일과 실행은 이미지의 rustc로 한다. 체크아웃한 저장소에서는 just web으로 에디터를 띄운다.

  • 이미지는 빌드할 때 웹 UI를 빌드하고 renk CLI와 작은 renk-web HTTP 서버를 포함한다. Deno는 빌드 단계에서만 쓴다.
  • 러너는 trpl 0.3.0을 포함하므로 스니펫은 Cargo 매니페스트 없이 trpl::block_on 같은 헬퍼를 쓸 수 있다.
  • 서비스는 호스트의 127.0.0.1:8080에 바인딩된다. 컨테이너는 /tmp를 빼고 읽기 전용이며, 스니펫은 /tmp에서 RENK_RUN_TIMEOUT_MS 제한 아래 컴파일되고 실행된다.
  • 실행 요청은 기본적으로 Rust 소스 64 KiB, 캡처 출력 256 KiB, 동시 실행 2개, 분당 실행 10회로 제한된다.
  • Cloudflare Tunnel 뒤에 둘 때는 서비스 앞에 Cloudflare Access와 /api/run 요청 빈도 제한을 둔다.

한계

Renk는 문법 프런트엔드다. 타입이 아니라 텍스트를 읽으므로 다음과 같은 한계가 있다:

  • a.b는 타입이 아니라 이름 바인딩으로 판단한다. 잘못 읽힌 이름은 대개 컴파일에 실패한다. 경로를 강제하려면 foo::new()를, 값 receiver를 강제하려면 (Unit).method()를 쓴다. 이런 점에서 이름을 찾지 못하면 renk read `String.nope` as the path `String::nope`; write `(String).nope` for a value처럼 다른 표기를 알려 주는 help 줄이 붙는다.
  • 표현식 뒤의 [...]는 name[...](이거나 ::나 .name 앞의 CamelCase Name[...]가 아니면 인덱싱이다. 그 밖의 제네릭 적용은 Rust의 ::<...>를 쓴다.
  • 매크로와 속성의 토큰 트리는 해석하지 않는 Rust 토큰이다. 매크로에 나열한 std 포맷 매크로와 표현식 매크로만 다시 쓴다.
  • proc-macro DSL은 view!의 여러 줄 { 블록을 빼고는 파싱하지 않는다.

변환의 빈틈

renk from-rust는 컴파일된 의미는 유지하지만 텍스트가 항상 같지는 않다:

  • 블록을 끝내는 unit 표현식이나 발산 표현식에 ;가 붙을 수 있다. panic!() 뒤나, arm이 None => v.clear() 같은 표현식인 match 문이 그렇다.
  • macro_rules! m {() => {처럼 항목과 같은 줄에서 시작하는 macro_rules! arm은 자기 줄로 옮겨지고 뒤에 ;가 붙는다.