Stage 3 데코레이터 작업 후기
PR: github.com/swc-project/swc/pull/6950
데코레이터가 Stage 3로 넘어왔다. 타입스크립트 5.0에 지원이 있어서 구현하긴 해야하는데 시간이 꽤 걸리는 작업이 될 것 같아서 미루다가 그냥 며칠 투자하기로 했다.
구현이 올바른지 어떻게 검증할까 잠깐 고민했는데 언제나처럼 바벨 테스트를 정답처럼 사용하기로 했다.
일단 입력과 출력을 베껴와서 커밋해놨다.
이러면 IDE가 바벨의 출력을 원본으로, swc
의 출력을 새로운 값으로 diff해서 보여준다.
내가 몇번 사용한 잡기술인데, vscode의 git 관련 기능을 적절히(?) 써먹는 것이다.
우선 테스트를 베껴왔으니 해당 테스트를 해석할 수 있는 간단한 테스팅 시스템을 만들었다.
swc
에 이미 존재하던 테스팅 시스템에 바벨의 options.json
해석 기능을 부분적으로 구현해서 연결한 것이라고 생각하면 된다.
options.json
이 잘 해석되는 걸 보고 구현에 들어갔다.
원본 소스코드를 볼까 3초 정도 고민했는데, 원본 소스코드를 보면 훨씬 쉽기 떄문이다.
근데 AST 처리가 나한텐 그다지 어려운 작업이 아니라서 그냥 소스코드 안 보고 새로 구현하기로 했다.
내가 여기서부터 한 일은 대부분 한 작업의 반복이다. 에디터에 트랜스폼 파일과 Diff 탭 켜놓고 Ctrl + Tab 계속 하면서 어디가 다른지 확인하고, 적당히 트랜스폼을 수정하는 것.
이 방식으로 새로운 구현체를 빠르게 만들어내려면 실행형 유닛 테스트를 돌릴 수 있게 만드는 작업이 최우선시되어야한다.
그래야 빠르다.
그래서 일단 헬퍼 호출부터 구현했다.
하지만 이 시점에서는 실행형 테스트를 사용하지 못했다.
데코레이터가 출력물에 존재하기 떄문인데, 실행할 일이 한참동안은 없으리란 걸 알았기에 swc_ecma_transforms_base
에 헬퍼 구현조차 안 넣었다.
작업하면서 슬슬 형태를 갖춰가고 있었는데, swc
의 다른 많은 패스들처럼 이 패스도 extra_stmts
같은 필드를 트랜스폼 타입에 선언해두고 추가되는 데이터를 러스트가 받아들일 수 있는 방식으로 AST에 추가한다.
근데 이 필드들에 선언된 값들을 가져가고 싶어하는 함수가 한개가 아니기 때문에
impl VisitMut for Decorator202303R {
fn visit_mut_stmts(&mut self, n: &mut Vec<Stmt>) {
let old_extra_stmts = self.extra_stmts.take();
let mut new = Vec::with_capacity(n.len());
// n 처리 및 self.extra_stmts 사용
*n = new;
self.extra_stmts = old_extra_stmts;
}
}
같은 코드가 많아진다. 이걸 안 하면 이상한 노드가 추가된 데이터를 가져가버린다.
// extra_stmt가 추가되어야 하는 위치
class Foo {
@extra_stmt
foo() {}
set eater(v) {}
}
위와 같은 코드에서, @extra_stmt
의 처리 과정에서 Stmt
가 self.extra_stmts
에 추가된다고 하자.
근데 위와 같은 코드가 없으면 eater
에 대한 setter 역시 AST상으론 ClassMethod
타입이고, ClassMethod.functiuon.body.stmts
의 타입이 Vec<Stmt>
기 때문에, setter가 self.extra_stmts
를 먹어버린다.
참 골때리는 이슌데 깔끔하게 API 레벨에서 막아줄 방법은 아직 못 찾았다.
이 내용은 플러그인 공식 문서에도 있는 내용이지만 이 PR 작업하면서 이 실수를 엄청나게 했기 때문에 적는다.
클래스 필드 => 메소드 => Private 필드 => Private 메소드 순으로 비지터 함수를 구현했다. 이렇게 한 건 출력물이 세트로 있길래 열어봤는데 Private은 출력이 더 많았기 때문이다. 이런 상황에선 간단한 걸로 구조를 잡고 복잡한 걸 추가로 구현하는 게 좋다.
하다보니 static
이 붙었는지에 따라 동작이 좀 다른 것 같더라.
그래서 추가 데이터 필드를 각각 static용 필드/non-static용 필드로 구분했다.
사실 처음엔 그렇게 많은 필드가 필요할 줄 몰랐다.
알았으면 필드를 한 개의 struct HelperData {}
로 묶은 뒤 for_static: HelperData
, for_instance: HelperData
처럼 반복을 줄였을 것이다.
근데 구현 끝난 뒤에 리팰터링하긴 귀찮더라.
그래서 말았다.
어찌어찌 실행형 테스트를 실행할 수 있는 정도까지 구현이 되었고, 빠른 구현을 위해 fixture 테스트는 diff 보는 목적으로만 쓰기 시작했다.
바벨 테스트셋이 정말 잘 되어있어서 편했다.
실행현 테스트인 exec.js
옆에 같은 내용을 스냅샷 테스트하기 위한 input.js
와 output.js
가 있었다.
그래서 실행형 테스트가 실패하면 output.js
diff 켜놓고 실행형 테스트가 통과할 때까지 트랜스폼을 수정했고, UPDATE=1 cargo test
로 실행현 테스트와 스냅샷 테스트를 동시에 돌렸다.
그러면 exec.js
의 중요한 파트가 어떻게 컴파일되었는지 알 수 있다.
근데 또 메소드랑 필드랑 초기화 방식이 전혀 다르더라.
그래서 필드를 4개인가 더 추가했다.
인스턴스 필드는 initProto
가 필요하고 static 필드는 initStatic
이 필요한데, 하다보니까 2개용 인자만 저장하는 걸론 모자라고 initProto
같은 Ident
자체도 저장해야해서 4개가 됐다.
역시 열심히 노가다로 실행형 테스트를 고쳤다.
Auto accessor와 duplicate keys를 제외한 필드 테스트들이 다 처리된 것 같아서, 클래스 자체에 데코레이터를 붙인 경우 어떻게 처리되나 봤는데 어지러웠다.
Class 표현식의 경우 SeqExpr
로 바뀌는데 여기까진 괜찮았다.
const dec = () => {};
const Foo =
(
@dec
class Bar {
bar = new Bar();
}
);
const foo = new Foo();
가
const dec = () => {};
const Foo =
(((_class = class Bar {
constructor() {
_defineProperty(this, "bar", new _Bar());
}
}),
([_Bar, _initClass] = _applyDecs(_class, [], [dec]).c),
_initClass()),
_Bar);
const foo = new Foo();
로 바뀌는 정도? 이 정도는 나한텐 쉽다.
근데 Class 선언의 경우 좀 문제가 있었는데...
@dec
class Foo {
static foo = new Foo();
}
const foo = new Foo();
같은 간단한 코드가
const dec = () => {};
let _Foo;
new ((() => {
class Foo {}
[_Foo, _initClass] = _applyDecs(Foo, [], [dec]).c;
})(), class extends _identity {
constructor() {
(super(_Foo), _defineProperty(this, "foo", new _Foo())), _initClass();
}
})();
const foo = new _Foo();```
처럼 컴파일된다.
이게 맞나 싶었는데 어련히 알아서 잘 했겠지하고 그냥 구현이나 했다.
처음엔 이게 무조건 새로운 클래스를 만드는 줄 알았다.
근데 하다보니까 아니더라.
static
멤버갸 하나 이상 있어야 저런 이상한 결과물이 나오는 것이었다.
모듈 전체를 대상으로 하는 변수 리네이밍 동작이 있어서 어떻게 처리할까 고민하다가 swc_ecma_utils
에 유틸리티 비지터를 하나 추가하고 그걸 호출하도록 했다.
언제였는지까지는 기억 안 나지만 변수 리네이밍은 다른 패스에서도 필요했던 것 같아서 공통 모듈에 넣은 것이다.
그리고 실행형 테스트를 계속 돌리면서 코드를 조금씩 고쳤다.
데코레이터 실행 순서 잡는 게 좀 시간을 먹었는데, 데코레이터의 해석 순서만 지킨다고 되는 게 아니더라.
실행 순서가 해석 순서랑 별개여서 삽질 좀 했다.
나한테 swc
트랜스폼 작업은 대체로 쉽기 때문에 dbg
를 쓸 일이 잘 없었는데, 이거 디버깅할 땐 dbg
까지 쓰면서 디버깅했다.
저걸 처리하고 테스팅을 몇번 더 반복하다보니까 auto accessor하고 duplicate keys만 남았는데, 이 두개는 다른 PR에서 처리하는 게 나을 것 같아서 작업을 마루리했다.