Stage 3 데코레이터 작업 후기

PR: https://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의 처리 과정에서 Stmtself.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.jsoutput.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에서 처리하는 게 나을 것 같아서 작업을 마루리했다.