본문 링크 (Original Link)

스위프트 로컬 리팩토링

2017.09.12

#

by Xi Ge, translated by pilgwon

Xcode 9은 새로운 리팩토링 엔진을 포함하고 있습니다. 그것은 하나의 스위프트 소스 파일안에서 로컬 또는 글로벌하게 코드를 변경할 수 있습니다. 예를 들어, 여러 파일의 이름뿐만 아니라 다른 언어의 메소드나 속성의 이름을 변경하는 등의 작업을 할 수 있습니다. 로컬 리팩토링의 로직은 컴파일러와 SourceKit에서 전부 구현하는 것입니다. SourceKit은 지금 스위프트 레포지토리에서 오픈 소스로 되어있습니다. 당신이 스위프트 애호가라면 리팩토링 작업에 공헌 할 수 있습니다. 이 게시글은 리팩토링이 Xcode에서 얼마나 간단하게 구현되고 보여질 것인지에 대해 설명합니다.

리팩토링의 종류

로컬 리팩토링은 단일 파일 범위 내에서 발생합니다. 로컬 리팩토링의 예제들은 메소드 추출 (Extract Method)Extract Repeated Expression(반복 표현 추출)을 포함합니다. 여러개의 파일을 가로질러 코드를 변경하는 전역 리팩토링과 같은 방법은(예를 들어 전역 이름 변경(Global Rename)) Xcode의 특별한 조정을 필요로 해서 현재 스위프트 코드만으로는 구현할 수 없습니다. 이 게시글은 그들만의 능력안에서 꽤나 강력한 로컬 리팩토링에만 집중할 것입니다.

리팩토링 액션은 에디터에서 유저의 커서 선택에 의해 초기화됩니다. 그들이 초기화되는 방법에 따라, 우리는 리팩토링 액션을 커서 기반(cursor-based)와 범위 기반(range-based)로 나눕니다. 커서 기반 리팩토링은 이름 변경 리팩토링과 같은 스위프트 소스 파일의 커서 위치로 충분히 지정된 리팩토링 대상이 있습니다. 대조적으로, 범위 기반 리팩토링은 메소드 추출 리팩토링처럼 타겟의 시작점과 끝점을 필요로 합니다. 이 두 카테고리의 구현을 용이하게 하기 위해, 스위프트 레포지토리는 소스 파일에서의 커서 위치와 범위에 관련된 몇몇의 질문들에 미리 분석돼있는 결과인 ResolvedCursorInfoResolvedRangeInfo를 제공합니다.

예를 들어, ResolvedCursorInfo는 소스 파일의 위치가 표현식의 시작을 가리키는지 여부를 알려줄 수 있으며, 만약 그렇다면, 해당 표현식에 해당하는 컴파일러 객체를 제공합니다. 또는, 커서가 이름을 찍고 있으면, ResolvedCursorInfo는 그 이름에 해당하는 선언을 제공합니다. 비슷하게, ResolvedRangeInfo는 범위안에 여러 항목 또는 종료 점이 있는지 여부와 같은 주어진 소스의 범위에 대한 정보를 캡슐화합니다.

스위프트를 위한 새로운 리팩토링을 구현할 때 우리는 바닥부터 시작할 필요가 없습니다. 대신에 우리는 ResolvedCursorInfoResolvedRangeInfo로 시작할 수 있고 그로 인해 리팩토링에 특화된 분석이 도출될 수 있습니다.

커서 기반 리팩토링

image1

커서 기반 리팩토링은 스위프트 소스 파일의 커서 위치에 의해 초기화됩니다. 리팩토링 액션은 리팩토링 엔진이 쓰는 메소드를 IDE에서 사용 가능한 액션을 보여주거나 변형을 하기 위해 구현합니다.

구체적으로, 사용 가능한 액션들을 보여주기 위해:

  1. 유저가 Xcode 에디터에서 위치를 선택합니다.
  2. Xcode가 그 위치에서 어떤 사용 가능한 리팩토링 액션이 있는지 확인하기 위해 sourcekitd에 요청을 합니다.
  3. 구현된 각 리팩토링 액션은 액션이 그 위치에 응용이 가능한지를 ResolvedCursorInfo 객체에게 물어봅니다.
  4. 응용 가능한 액션들의 리스트가 sourcekitd의 리스폰스로 반환되고 Xcode에 의해 유저에게 보여집니다.

유저가 사용 가능한 액션 하나를 고르면:

  1. Xcode는 sourcekitd에 선택된 액션을 행하기 위해 요청을 합니다.
  2. 특정한 리팩토링 액션은 동일한 위치에서 파생된 ResolvedCursorInfo 객체로 문의되어 해당 액션이 적용 가능한지 확인합니다.
  3. 리팩토링 액션은 텍스트 소스 편집으로 변환을 수행하도록 요청됩니다.
  4. 소스 편집은 sourcekitd의 응답으로 리턴되며 Xcode 에디터에 의해 편집기에 의해 적용됩니다.

문자열 현지화(String Localization) 리팩토링을 구현하기 위해, 우리는 이 리팩토링을 RefactoringKinds.def 파일의 도입 부분에 아래와 같이 선언해주어야 합니다:

CURSOR_REFACTORING(LocalizeString, "Localize String", localize.string)

CURSOR_REFACTORING는 이 리팩토링이 커서 위치에 의해 초기화되고 그래서 구현 할 때 ResolvedCursorInfo를 사용할 것을 나타냅니다. 첫 번째 필드인 LocalizeString은 스위프트 코드베이스의 이 리팩토링 내부에서 쓸 이름을 나타냅니다. 이 예제에서, 이 리팩토링에 대응하는 클래스의 이름은 RefactoringActionLocalizeString입니다. "Localize String"라는 문자열은 이 리팩토링의 UI에서 유저에게 보여질 이름을 의미합니다. 마지막으로, “localize.string”은 스위프트 툴체인이 소스 에디터와 통신하는 데 사용하는 리팩토링 액션을 식별하는 안정적인 키입니다. 또한 이 항목을 사용하면 C++ 컴파일러 내에서 String Localization 및 해당 호출자에 대한 클래스 스텁을 생성하게 해줍니다. 따라서, 우리는 필요한 기능 구현에 집중할 수 있습니다.

그후에, 우리는 Xcode를 가르치기 위해 두 가지 함수를 구현해야 합니다:

  1. 언제가 리팩토링 액션을 보여주기 적절한 때인가.
  2. 유저가 리팩토링 액션을 하려고 할 때 어떤 코드 변경을 적용할 것인가.

두 가지 선언 다 앞에서 언급했던 항목에 의해 자동으로 생성됩니다. 1번을 충족하려면, 우리는 Refactoring.cpp에 있는 RefactoringActionLocalizeString의 함수인 isApplicable를 아래와 같이 구현해야 합니다:

bool RefactoringActionLocalizeString::
  isApplicable(ResolvedCursorInfo CursorInfo) {
    if (CursorInfo.Kind == CursorInfoKind::ExprStart) {
      if (auto *Literal = dyn_cast<StringLiteralExpr>(CursorInfo.TrailingExpr) {
        return !Literal->hasInterpolation(); // Not real API.
      }
    }
  }

ResolvedCursorInfo 객체를 입력으로 사용하면, 사용 가능한 리팩토링 메뉴에 “localize string”을 언제 덧붙일 것인지 검사하는 것이 거의 확실합니다. 이 경우에, 커서 포인트가 표현식의 시작을 가리키는지(Line 3)와 표현식이 Interpolation(Line 5)가 없는 문자열 리터럴인지(Line 4)를 충족하는가에 대한 검사를 합니다.

다음, 우리는 커서 아래에 있는 코드가 리팩토링 액션이 적용됐을 때 어떻게 바뀌어야 하는지 구현해야 합니다. 그러기위해, 우리는 RefactoringActionLocalizeString의 메소드인 performChange를 구현해야 합니다. performChange 구현 과정에서, 우리는 isApplicable를 받은 ResolvedCursorInfo 객체에 접근할 수 있습니다.

bool RefactoringActionLocalizeString::
  performChange() {
    EditConsumer.insert(SM, Cursor.TrailingExpr->getStartLoc(), "NSLocalizedString(");
    EditConsumer.insertAfter(SM, Cursor.TrailingExpr->getEndLoc(), ", comment: \"\")");
    return false; // Return true if code change aborted.
  }

String Localization을 그대로 예제로 써서, performChange 함수는 구현하기 매우 간단합니다. 함수의 본문에 Line 3과 Line 4에서 그려지듯이, 우리는 EditConsumer를 사용하여 적절한 Foundation API 호출로 커서가 가리키는 표현식을 텍스트로 편집 할 수 있습니다.

범위 기반 리팩토링

image2

위의 이미지가 보여주듯이, 범위 기반 리팩토링은 스위프트 소프 파일의 연속으로 선택된 범위에 의해 초기화됩니다. 추출 표현식(Extract Expression)의 구현을 예로 들자면, 우리는 먼저 RefactoringKinds.def에 아래와 같이 정의해야 합니다.

RANGE_REFACTORING(ExtractExpr, "Extract Expression", extract.expr)

이 항목은 Extract Expression 리팩토링이 범위 선택에 의해 초기화 됐고, 기본적으로 ExtractExpr 이름이 설정됐고, "추출 표현식"을 보여주는 이름으로 사용하고, 서비스 커뮤니케이션 목적으로 “extract.expr” 키 값을 쓴다는 것을 의미하는 정의입니다.

이 리팩토링이 사용 가능하게 Xcode에게 가르치려면, 우리는 Refactoring.cpp의 이 리팩토링을 위해 isApplicable을 구현해야 하고, 약간 다른 점이 있다면 ResolvedCursorInfo 대신에 ResolvedRangeInfo를 입력으로 사용한다는 것입니다.

bool RefactoringActionExtractExpr::
  isApplicable(ResolvedRangeInfo Info) {
    if (Info.Kind != RangeKind::SingleExpression)
      return false;
    auto Ty = Info.getType();
    if (Ty.isNull() || Ty.hasError())
      return false;
    ...
    return true;
  }

비록 앞에서 언급된 String Localization 리팩토링보다 약간 더 복잡하지만, 이 구현 방법은 설명이 없어도 명백한 구현 방법이기도 합니다. 라인 3 에서 4는 주어진 범위의 종류가 추출을 진행하기 위한 단일 표현식인지 확인합니다. 라인 5에서 7은 추출된 표현식이 잘 쓰인 타입인지 확인합니다. 추가적으로 확인해야 하는 조건들은 이 예제에서만 생략했습니다. 관심있는 독자들은 Refactoring.cpp를 보면 더 자세한 정보를 확인하실 수 있습니다. 코드 변경 파트에서, 우리는 텍스트 편집을 하는 것 대신에 같은 ResolvedRangeInfo 인스턴스를 사용할 수 있습니다:

bool RefactoringActionExtractExprBase::performChange() {
    llvm::SmallString<64> DeclBuffer;
    llvm::raw_svector_ostream OS(DeclBuffer);
    OS << tok::kw_let << " ";
    OS << PreferredName;
    OS << TyBuffer.str() <<  " = " << RangeInfo.ContentRange.str() << "\n";
    Expr *E = RangeInfo.ContainedNodes[0].get<Expr*>();
    EditConsumer.insert(SM, InsertLoc, DeclBuffer.str());
    EditConsumer.insert(SM,
                       Lexer::getCharSourceRangeFromSourceRange(SM, E->getSourceRange()),
                       PreferredName)
  return false; // 코드 변경이 취소되면 true를 반환합니다.
}

라인 2에서 6은 추출중인 표현식의 초기화 된 값을 사용하여 지역 변수의 선언을 구성합니다 (예시: let extractedExpr = foo()). 라인 8은 로컬 컨텍스트의 적절한 위치에 선언을 끼워넣고, 라인 9는 표현식의 원래 모양을 새로 선언된 변수에 대한 참조로 변경합니다. 코드 예제에서 보았듯이, performChange 함수 내에서, 우리는 유저의 선택을 위해 원래의 ResolvedRangeInfo뿐만 아니라, edit consumer와 source manager 같은 중요한 유틸리티에도 접근할 수 있고 이것은 구현을 더욱 편하게 만들어주었습니다.

진단

리팩토링 액션은 다양한 이유로 자동 코드 변경 동안 취소돼야 할 때가 있습니다. 이런 상황이 일어나면, 리팩토링 구현은 유저에게 실패 이유를 진단 하는 것을 통해 소통할 수 있습니다. 리팩토링 진단은 컴파일러 자기자신과 동일한 매커니즘을 사용합니다. 이름 변경 리팩토링을 예로 들면, 주어진 이름이 불가능한 스위프트 식별자라면 에러 메세지를 던집니다. 그러기 위해, 우리는 먼저 아래의 진단을 위한 항목을 DiagnosticsRefactoring.def에 선언해야 합니다.

ERROR(invalid_name, none, "'%0' is not a valid name", (StringRef))

선언 후에, 우리는 진단을 isApplicableperformChange에서 사용할 수 있습니다. 로컬 이름 변경 리팩토링의 경우, Refactoring.cpp의 진단은 다음과 같을 것입니다:

bool RefactoringActionLocalRename::performChange() {
  ...
    if (!DeclNameViewer(PreferredName).isValid()) {
      DiagEngine.diagnose(SourceLoc(), diag::invalid_name, PreferredName);
      return true; // 코드 변경이 취소되면 true를 반환합니다.
    }
  ...
  }

테스팅

새로운 리팩토링 액션을 구현하기 위해, 우리는 다음과 같은 내용을 테스트해야 합니다:

  1. 문맥상으로 사용가능한 리팩토링이 적절하게 덧붙여져야 합니다.
  2. 자동 코드 변경은 유저의 코드를 올바르게 업데이트해야 합니다.

이 두 부분은 컴파일러에 의해 빌드된 swift-refactor 커맨드 라인 유틸리티를 사용해 테스트됩니다.

문맥 리팩토링 테스트

  func foo() {
    print("Hello World!")
  }
  // RUN: %refactor -source-filename %s -pos=2:14 | %FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING
  // CHECK-LOCALIZE-STRING: Localize String

다시 한 번 문자열 현지화를 예제로 들어봅시다. 위의 코드 스니펫은 문맥 리팩토링 액션을 위한 테스트입니다. 비슷한 테스트들을 test/refactoring/RefactoringKind/에서 찾을 수 있습니다.

이제 **RUN** 라인을 더 자세히 알아봅시다. %refactor 유틸리티의 쓰임부터 시작해보겠습니다:

%refactor -source-filename %s -pos=2:14 | %FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING

이 라인은 유저가 문자열 리터럴인 “Hello World!”에 커서를 놓았을 때 사용 가능한 모든 리팩토링의 이름을 쌓아둘 것입니다. %refactor는 테스트가 실행될 때 swift-refactor에게 전체 경로를 주기 위해 테스트 실행자에 의해 대체되는 별칭(alias)입니다. -pos는 문맥 리팩토링 액션을 어디서 가져와야 하는지 커서 위치를 제공합니다. String Localization 리팩토링은 커서 기반이기 때문에, -pos를 하나만 설정하는 것으로 충분합니다. 범위 기반 리팩토링을 테스트하기 위해서는, 우리는 리팩토링 타겟의 끝부분 위치를 나타내기 위한 -end-pos를 명시할 필요가 있습니다. 그리고 모든 위치는 line:column의 포맷을 가집니다.

도구의 결과값이 우리가 예상한 것인지 확인하기 위해, 우리는 %FileCheck 유틸리티를 사용합니다:

%FileCheck %s -check-prefix=CHECK-LOCALIZE-STRING

이것은 접두사CHECK-LOCALIZE-STRING을 가진 모든 다음 행에 대해 %refactor의 출력 텍스트를 검사합니다. 이 경우, 그것은 사용 가능한 리팩토링이 Localize String을 포함하고 있는지 확인합니다. 우리가 올바른 커서 위치에 올바른 액션을 보여줬는지 테스트 하기 위해서, 우리는 문자열 리터럴과 같은 상황에 사용 가능한 리팩토링이 잘 못 표시되진 않았는지 테스트할 필요가 있습니다.

코드 변형 테스트

또한 우리는 리팩토링을 적용할 때도 자동 코드 변경이 우리의 예상과 일치하는지 테스트해야 합니다. 이에 대한 준비로, 우리는 swift-refactor에게 우리가 테스트하고 있는 액션이 어떤 것인지 알기 위한 리팩토링 종류 플래그를 가르쳐야 합니다. 이를 달성하기 위해, 다음과 같은 항복이 swift-refactor.cpp에 추가됩니다:

clEnumValN(RefactoringKind::LocalizeString, "localize-string", "Perform String Localization refactoring"),

다음과 같은 항목으로, swift-refactor는 특히 문자열 현지화 같은 코드 변형을 테스트 할 수 있습니다. 대표적인 코드 변형 테스트는 두 파트로 나뉘어져 있습니다:

  1. 리팩토링 전 코드 스니펫.
  2. 변형 후 예상하는 출력 결과.

테스트는 지정된 리팩토링을 (1)에 적용하고, 그 결과를 (2)와 비교합니다. 이 둘이 동일하면 테스트 통과고, 아니면 실패입니다.

func foo() {
  print("Hello World!")
}
// RUN: rm -rf %t.result && mkdir -p %t.result
// RUN: %refactor -localize-string -source-filename %s -pos=2:14 > %t.result/localized.swift
// RUN: diff -u %S/Iutputs/localized.swift.expected %t.result/localized.swift
func foo() {
  print(NSLocalizedString("Hello World!", comment: ""))
}

위의 두 코드 스니펫은 의미있는 코드 변형 테스트로 구성되어 있습니다. 라인4는 리팩토링 결과를 잠시동안 저장할 공간을 준비합니다. 라인 5에서는 새롭게 추가된 -localize-string을 사용하여 "Hello World!"의 시작 부분에서 코드 변형을 하고, 결과 값을 임시 저장소에 저장해둡니다. 마지막으로 라인 6은 두번째 코드 예제에서 그려진 예상된 출력과 결과를 비교합니다.

Xcode에 붙이기

위의 모든 조각들을 스위프트 코드로 구현이 끝나면, 우리는 Xcode에 로컬 빌드된 오픈 소스 툴체인에 의해 새롭게 추가된 리팩토링을 테스트하거나 사용할 준비가 끝납니다.

  1. 오픈 소스 툴체인을 로컬로 빌드하기 위해 build-toolchain을 실행합니다.
  2. 툴체인의 압축을 풀고 Library/Developer/Toolchains으로 복사합니다.
  3. 아래의 이미지처럼, Xcode -> Toolchains를 통해 로컬 툴체인을 지정해줍니다.

image3

잠재적인 로컬 리팩토링 아이디어

이 게시물은 새로운 리팩토링 엔진에서 구현할 수 있는 몇가지 사항을 살짝 건드리기만 했습니다. 만약 당신이 리팩토링 엔진을 추가적인 변형을 위해 확장하는것에 관심이 있다면, 스위프트의 이슈 데이터베이스에서 구현을 기다리고 있는 리팩토링 변형에 대한 몇 가지 아이디어가 있습니다. 만약 당신이 새로운 리팩토링 아이디어를 제안하려고 한다면, 스위프트의 이슈 데이터베이스에 작업을 올리고 Refactoring이라고 레이블을 붙이는 것만으로도 충분할 것입니다.

리팩토링 변형을 구현하는 것에 더 도움이 필요하다면, 문서를 봐주시고 swift-dev 메일링 리스트에 질문하는 것을 어렵게 생각하지 마세요.