How to write a plugin for Prettier

adultlee·2024년 1월 9일
post-thumbnail

해당 글은 Franco Victorio 님의 How to write a plugin for Prettier 을 번역한 글 입니다.

일부 의역이 포함되어 있습니다.

이 글에서는 Prettier에 대한 매우 간단한 플러그인을 구축하는 방법을 보여드릴 것입니다. 여러분은 플러그인의 구조, 테스트 방법, 그리고 물론 플러그인 작성 방법을 배울 것입니다.
저는 이 분야의 전문가는 아니지만, Solidity 플러그인에 기여하며 많은 것을 배웠고, 이 주제에 대한 자료가 많지 않다는 것을 알게 되었습니다. 공식 문서를 제외하고 말이죠.

예제 언어로 TOML을 사용할 것입니다. TOML을 선택한 이유는 문법이 쉽고, 제가 알기로는 Prettier 플러그인이 없기 때문입니다. 결과물은 사용 가능한 플러그인이 되지 않을 것입니다만, 운이 좋다면 제대로 된 플러그인을 개발하기 위해 충분한 지식을 얻을 수 있을 것입니다.

왜 당신은 이 과정을 공부해야할까요?

여기엔 두가지 매력적인 이유가 있습니다.

첫째, 아직 지원되지 않는 언어(예: TOML 및 Dockerfile, 그리고 아마도 많은 다른 언어들)에 대한 플러그인을 만들 수 있게 됩니다.

둘째, 이미 존재하는 플러그인에 기여할 수 있게 됩니다. 게다가 Prettier의 핵심 자체가 플러그인 API를 사용하여 작성되었기 때문에, 원한다면 메인 저장소에도 기여할 수 있습니다.

How Prettier works?

Prettier의 핵심 기능은 매우 간단합니다

코드(문자열)를 가져와서 AST(Abstract Syntax Tree, 코드의 표현)로 변환한 다음, AST를 사용하여 코드를 인쇄합니다. 이는 원래 코드의 스타일이 (거의) 완전히 무시된다는 것을 의미합니다. 원본 블로그 게시물에서 더 많은 정보를 얻을 수 있습니다.

새로운 prettier plugin을 만드는 과정에서 중요한 부분은 바로 코드를 AST로 변환하는 파서(parser)와 이를 받아 예쁘게 인쇄하는 함수(printer) 가 필요하다는 것입니다.
우리의 초기 설정에는 이미 toml-node를 사용하는 파서가 구성되어 있으므로, 우리는 프린터 함수에만 신경 쓰면 됩니다.

Setup

먼저 필요한 모든 보일러플레이트가 포함된 리포지토리를 클론할 것입니다. 곧 그 내용을 설명해 드리겠습니다. 클론한 후에, 최상위 디렉토리로 이동하여 npm install을 실행하여 의존성을 설치합니다. 이제 다음 명령어를 사용하여 예제 파일(example.toml)에서 플러그인을 실행할 수 있어야 합니다:

./node_modules/.bin/prettier --plugin . example.toml

물론 package.json에 이에 대한 npm 스크립트도 있습니다.

package.json의 npm run example 명령어도 동일하게 작동할 것입니다.
하지만 이 방식으로 실행시킨다면, 당신이 원하는 파일(example.toml 과 같은)에서 플러그인을 실행하는 방법을 보여줍니다.

(+역자 물론 package.json의 스크립트를 수정해서 테스트를 해도 동일합니다.)

명령어를 실행한 후에 아무런 출력도 보이지 않을 것이고, 그것이 정상입니다. 현재 우리의 플러그인은 아무것도 출력하지 않습니다: 프린터 함수가 AST를 받으면 단순히 빈 문자열을 반환합니다.

npm test를 실행하여 초기 테스트를 실행할 수도 있습니다. 우리의 테스트는 Jest를 사용하며 스냅샷을 활용할 것입니다. 하지만 설정이 이미 완료되어 있으므로 해야 할 일은 새로운 픽스처를 추가하는 것뿐입니다.
이 초기 테스트는 tests/StringAssignments/example.toml의 내용을 포맷하고 스냅샷에 있는 예상 출력과 비교할 것입니다. 우리의 모든 테스트는 올바른 포맷의 TOML 파일과 스냅샷을 비교하는 것입니다. 물론 이 테스트는 실패할 것이지만, 우리의 첫 번째 목표는 이를 통과시키는 것입니다.

우리가 작성할 모든 코드는 src/index.js 파일 안에 있을 것입니다. 사실, 모든 것은 단일 함수인 printToml 내에 있을 것입니다. 파일의 나머지 부분을 살펴볼 수 있지만, 세부 사항에 대해 걱정하지 않아도 됩니다. 궁금하다면, 여기에서 모든 것이 설명되어 있습니다.

코드를 직접 작성하는 대신 완성된 코드를 읽고 싶다면, 대신 완성된 브랜치를 체크아웃하면 됩니다.

The printer function

printToml 함수는 매우 간단합니다. 이 함수는 세 가지 인자를 받습니다:

  1. path, AST(추상 구문 트리)에서 하나의 노드를 나타냅니다.
  2. options, Prettier에 주어진 설정을 나타냅니다. 이는 .prettierrc 파일과 명령어에 주어진 플래그 등을 결합한 것을 포함합니다.
  3. 그리고 print, 재귀적으로 프린터 함수를 호출하는 방법입니다.

path가 AST의 루트가 아니라 어떤 노드라고 말했음을 주목하세요. 이는 함수가 재귀적으로 호출되기 때문입니다. 예를 들어, 함수의 본문을 가지고 있다면, 개별 문장을 따로 예쁘게 인쇄한 후 이 결과를 가지고 무언가를 할 수도 있습니다. 이는 우리가 계속 진행함에 따라 더 명확해질 것입니다.

이것이 우리 함수의 보일러플레이트입니다:

이 설명은 printToml 함수의 기본 구조와 작동 원리를 설명합니다. 함수는 AST의 각 노드를 개별적으로 처리하며, 이를 통해 코드의 각 부분을 예쁘게 포맷팅하는 역할을 수행합니다. 재귀적 호출 방식은 함수가 다양한 노드 유형과 구조를 효과적으로 처리할 수 있게 해줍니다.


function printToml(path, options, print) {
  const node = path.getValue()

  if (Array.isArray(node)) {
    return concat(path.map(print))
  }

  switch (node.type) {
    default:
      return ''
  }
}

첫 번째 줄은 단순히 path에서 AST 노드를 추출합니다. 이는 path가 AST 노드와 관련된 추가 정보와 로직을 가지고 있기 때문입니다.

그 다음에는 노드가 배열인지 확인하는 이상한 블록이 있습니다. 이는 초기 호출에서만 필요한 것이며, 우리가 사용하는 파서는 코드를 노드의 트리가 아닌 노드의 리스트로 표현하기 때문입니다. 이에 대해 걱정하지 않아도 되지만, 나중에 우리 플러그인에 심각한 제한을 가할 수 있으므로 기억해 두어야 합니다.

마지막으로 switch 문이 있습니다. 여기서 우리는 대부분의 시간을 보낼 것입니다. 우리가 가진 로직은 매우 간단합니다: AST 노드의 유형을 확인하고 그에 따라 행동합니다. 이제 그것을 채워나가기 시작합시다.

이 설명은 printToml 함수의 핵심 작동 원리를 설명합니다. 함수는 먼저 path에서 AST 노드를 추출하고, 노드가 배열인지 확인하는 로직을 수행합니다. 이후에는 switch 문을 통해 노드의 유형에 따라 적절한 처리를 하게 됩니다. 이 과정은 AST의 다양한 노드 유형을 처리하고, 코드를 예쁘게 포맷팅하는 데 중요한 역할을 합니다.

A simple assignement

테스트를 살펴보면 두 개의 키/값 쌍이 포함되어 있다는 것을 알 수 있습니다. 첫 번째 쌍을 나타내는 노드는 다음과 같은 형태일 것입니다:


{
  type: 'Assign',
  value: {
    type: 'String',
    value: 'TOML Example',
    line: 1,
    column: 9
  },
  line: 1,
  column: 1,
  key: 'title'
}

(이것을 어떻게 알 수 있을까요? 이를 알아내는 방법은 여러 가지가 있습니다: 기본적인 console.log 사용, 노드 REPL에서 파서 사용, 또는 ndb를 사용하여 플러그인을 실행하고 값을 검사하는 것 등이 있습니다.)

여기에는 두 가지 흥미로운 점이 있습니다. 첫 번째는 우리가 switch에서 사용하는 type 속성입니다. 두 번째는, 우리 쌍의 키가 단순한 문자열인 반면, 값은 String 타입의 또 다른 AST 노드라는 것입니다.

그래서 우리가 할 첫 번째 일은 Assign 노드에 대한 절을 추가하는 것입니다:

이 접근 방식은 키/값 쌍을 나타내는 AST 노드를 처리하는 방법에 초점을 맞춥니다. 각 Assign 노드는 키와 값으로 구성되며, 이러한 구조는 플러그인이 코드를 어떻게 포맷팅하는지 결정하는 데 중요한 역할을 합니다. Assign 노드에 대한 처리는 플러그인이 키/값 쌍을 어떻게 해석하고 인쇄할지를 정의하는 첫 단계입니다.

case 'Assign':
  return concat([node.key, ' = ', path.call(print, 'value'), hardline])

여기에서 다뤄야 할 내용이 많지만, 주요 아이디어는 쉽게 이해할 수 있습니다: 우리는 Prettier에게 할당을 네 가지 요소를 연결하여 인쇄한다고 알려주고 있습니다:

  1. 키입니다. 이것은 단순한 일반 문자열임을 기억하세요.
  2. 공백으로 패딩된 리터럴 등호입니다.
  3. 할당 값의 예쁘게 인쇄된 결과물입니다, 그것이 무엇이든간에.
  4. 그리고 하드라인입니다.

concathardline은 무엇일까요? 이들은 빌더라고 불리며, Prettier에 의해 노출된 함수들과 값들입니다. 우리가 원하는 결과를 구축하기 위해 사용합니다. concat은 이미 임포트했지만, 우리가 사용하는 빌더 목록에 hardline을 추가해야 합니다:


const {
  doc: {
    builders: { concat, hardline }
  }
} = require('prettier')

concat 빌더는 이해하기 쉽습니다. 그것은 Prettier에게 주어진 부품 리스트를 단순히 연결하라고 지시합니다. 그리고 hardline은 "어떤 상황에서도 줄 바꿈을 하라"는 의미입니다. 빌더들의 전체 목록은 여기에서 확인할 수 있습니다.

path.call(print, 'value') 부분은 어떤가요? 이것은 Prettier의 관용구이며, "'value' 키에 있는 노드를 사용하여 프린터 함수를 재귀적으로 호출하라"는 의미입니다. 그렇다면 왜 그냥 print(node.value)를 하지 않는 걸까요? 프린터 함수는 노드가 아니라 경로, 즉, 감싸진 노드를 기대하기 때문입니다. 따라서 이렇게 해야 합니다.

이것만 추가하고 테스트를 실행하면 실패할 것입니다. 차이점은 키와 등호는 인쇄되었지만 값은 인쇄되지 않았다는 것을 알려줍니다. 이는 값이 String 타입의 노드이고 아직 그에 대한 절이 없기 때문에 말이 됩니다. 다행히 그 절은 매우 간단합니다. 다시 한 번 AST 하위 노드를 살펴보고 추측해 보세요.

네, 그렇게 간단합니다:

case 'String':
  return concat(['"', node.value, '"'])

당신은 단순히 return node.value를 생각했을 수도 있지만, 그것은 잘못된 방법이었을 것입니다. 그 경우에는 문자열의 전체가 아닌 내용만을 인쇄하게 됩니다. 예를 들어, foo = "bar"foo = bar로 인쇄되었을 것입니다.

이제 테스트를 다시 실행하면 통과해야 합니다.

Adding support for other values

TOML은 문자열 외에도 다양한 데이터 유형을 지원하며, 우리의 플러그인도 이를 지원해야 합니다. 루트 디렉토리에 있는 예제를 살펴보면, 숫자, 불리언, 날짜, 그리고 리스트가 포함되어 있다는 것을 알 수 있습니다.

숫자와 불리언은 쉽습니다:

case 'Integer':
  return node.value.toString()
case 'Boolean':
  return node.value.toString()

숫자와 불리언을 문자열로 변환해야 합니다. 왜냐하면 이것이 Prettier가 기대하는 방식이기 때문입니다. 그것이 전부입니다.

날짜는 조금 더 복잡하며, 여기서 우리가 사용하는 파서의 첫 번째 제한에 부딪힐 것입니다. 날짜 할당의 AST 표현은 다음과 같습니다:


{
  type: 'Assign',
  value: {
    type: 'Date',
    value: 1979-05-27T15:32:00.000Z,
    line: 5,
    column: 7
  },
  line: 5,
  column: 1,
  key: 'dob'
}

날짜 값에 주목하세요. 그것은 Date 객체로, 날짜의 고유한 표현입니다. 그러나 TOML 사양을 살펴보면 날짜를 여러 가지 형식으로 지정할 수 있다는 것을 알 수 있습니다. 이는 파싱하는 동안에 우리에게 손실되므로, 우리는 항상 같은 표현으로 날짜를 인쇄할 것입니다.

case 'Date':
  return node.value.toISOString()

이것은 전혀 좋지 않습니다! 하지만 제대로 처리하려면 날짜의 원래 표현을 알아야 합니다. 노드의 위치와 원본 텍스트(options.originalText에서 받는)를 사용하여 이를 얻을 수 있지만, AST에 원래 값을 유지하는 파서가 있는 것이 더 좋을 것입니다. 우리가 사용하는 파서가 이를 수행하지 않기 때문에, 우리는 이 방식에 만족해야 합니다.

Tables

TOML에서는 사양에서 "테이블"이라고 부르는 것으로 다양한 섹션을 구분할 수 있지만, 우리가 사용하는 파서는 ObjectPath 타입을 할당합니다. AST 노드는 다음과 같은 형태를 가집니다:

{
  type: 'ObjectPath',
  value: [ 'owner' ],
  line: 3,
  column: 1
}

보시다시피, 노드의 값은 문자열이 아니라 배열입니다. 이는 [servers.alpha]와 같은 중첩된 섹션이 있을 수 있기 때문입니다. 이를 다음 절로 인쇄합니다:

case 'ObjectPath':
  return concat(['[', node.value.join('.'), ']', hardline])

여기서는 새로운 것이 없습니다. 값의 각 부분을 마침표로 연결하고 모든 것을 대괄호로 둘러쌉니다.

Arrays

지금까지 우리가 한 모든 것은 매우 간단했습니다. 배열은 조금 더 복잡하며, 몇 가지 결정을 내려야 할 것입니다. 배열을 인쇄하는 방법은 여러 가지가 있습니다. 예를 들어:

arr1 = [1, 2, 3]
arr2 = [ 1, 2, 3 ]
arr3 = [1,2,3]
arr4 = [
  1,
  2,
  3
]

Prettier는 이런 상황에서 보통 다음과 같이 처리합니다: 배열이 한 줄에 맞으면 한 줄에 인쇄합니다. 그렇지 않으면, 각 요소를 각자의 줄에 인쇄합니다. 그래서 배열이 맞는 경우에는 arr1 방식을 사용하고, 맞지 않는 경우에는 arr4와 같은 방식으로 인쇄합니다.

이것이 어렵게 보일 수 있지만, Prettier가 도와줄 수 있습니다. 우리가 원하는 것을 수행하는 절은 다음과 같습니다:

case 'Array':
  return group(
    concat([
      '[',
      indent(
        concat([
          softline,
          join(concat([',', line]), path.map(print, 'value'))
        ])
      ),
      softline,
      ']'
    ])
  )

이 부분은 Prettier 플러그인 개발에서 복잡한 표현을 처리하는 방법에 대한 설명입니다. 여기서는 path.map(print, 'value') 표현을 사용하여, 주어진 노드의 'value' 키에 있는 하위 노드 배열에 대해 프린터 함수를 호출하고, 결과로 배열을 반환하는 방법을 설명합니다. 이것은 node.value.map(print)와 유사하지만, 우리가 직접적으로 그렇게 할 수 없음을 기억해야 합니다.

그런 다음, 리스트의 각 요소를 예쁘게 인쇄한 결과가 있는 배열을 가지게 됩니다. 다음 단계는 쉼표를 추가하는 것입니다. 이를 위해 join 빌더를 사용합니다. 이 함수의 서명은 join(separator, list)로, 주어진 구분자로 부품 리스트를 결합합니다. 예를 들어, concat(["1", ",", "2", ",", "3"])join(",", ["1", "2", "3"])과 동일합니다. 이 경우에는 join(",", path.map(print, 'value'))을 사용할 수 있습니다. 하지만 리스트가 한 줄에 맞을 때 쉼표 뒤에 공백이 있어야 하고, 분할될 때 줄바꿈이 필요합니다. 이는 line 빌더를 사용하여 수행되며, 이 때문에 concat([",", line])으로 결합합니다. 문서에서는 이렇게 명확하게 설명합니다:

이러한 설명은 Prettier 플러그인이 복잡한 데이터 구조를 어떻게 처리하는지 이해하는 데 중요한 부분을 제공합니다. 배열 내부의 요소들을 예쁘게 인쇄하고, 적절한 구분자와 줄바꿈을 추가하는 방법을 이해하는 것은 플러그인이 코드의 다양한 구조를 정확하게 반영하고 포맷팅하는 데 필요합니다.

줄 바꿈을 지정합니다. 표현이 한 줄에 맞으면 줄 바꿈은 공간으로 대체됩니다. 줄 바꿈은 항상 현재 들여쓰기 수준으로 다음 줄을 들여씁니다.

그래서 우리는 리스트가 한 줄에 맞으면 쉼표와 공간으로 각 값을 구분하여 인쇄하고, 맞지 않으면 공간을 줄 바꿈으로 대체합니다. 우리는 준비가 되었어야 합니다, 맞죠? 그저 여는 대괄호와 닫는 대괄호를 추가하고 끝내면 됩니다. 하지만 그렇지 않습니다. 왜냐하면 우리는 리스트를 분할할 때 각 요소를 들여쓰기하고 싶기 때문입니다.

우리는 지금까지 한 것을 indent(concat([softline, ...]))로 둘러싸서 이를 수행합니다. 여기서 무슨 일이 일어나고 있는 걸까요? 우선 리스트의 시작 부분에 softline을 둡니다. softlineline과 매우 비슷하지만, 차이점은 모든 것이 한 줄에 맞을 경우 softline은 빈 문자열로 대체된다는 것입니다. 우리는 들여쓰기를 증가시키는 indent 빌더도 사용합니다. 모든 것이 한 줄에 맞을 때 우리는 줄 바꿈이 없을 것이므로 indent는 아무것도 하지 않을 것입니다.

거의 다 왔습니다! 그 후, 우리는 모든 것을 concat('[', ..., softline, ']')로 둘러쌉니다. 우리는 단지 대괄호를 추가하는 것입니다. 닫는 대괄호 앞에 softline을 추가하고, 그것이 indent 빌더 밖에 있기 때문에 ]은 우리가 시작한 것과 같은 들여쓰기를 가질 것입니다. 그렇지 않으면 우리의 리스트는 다음과 같이 보일 것입니다:

arr = [TOML spec
  1,
  2
  ]

마지막으로, 모든 것을 group 호출로 둘러싸게 됩니다. 이것은 빌더로써, 내부의 모든 것을 한 줄에 맞추려고 시도합니다. 만약 맞지 않는다면, 줄과 softline을 줄바꿈으로 대체하기 시작합니다. 실제로는 조금 더 복잡하지만, 현재로서는 그 설명으로 충분합니다. 그것의 미묘한 차이점을 보려면 문서를 확인하세요.

다시 말하지만, 이것은 어렵게 보일 수 있지만, Prettier와 함께 놀기 시작하면 금방 익숙해질 것입니다. 이 모든 것은 Prettier가 얼마나 강력한지 생각해 보면 알 수 있습니다. 우리는 몇 가지 구축 블록만 사용하여 어떤 리스트든 예쁘게 인쇄했습니다. 실제로, 이것은 중첩된 리스트에도 작동할 것이며, 그것들이 얼마나 깊은지는 상관없습니다!

Aside: How to experiment

문서를 읽고 몇 가지 예제를 사용하여 전체 플러그인을 실행하는 것 외에 빌더들이 어떻게 상호 작용하는지 어떻게 확인할 수 있을까요? node REPL을 사용하여 Prettier와 상호 작용할 수 있다는 것을 알게 됩니다. 먼저 REPL을 시작하고 몇 가지를 가져와야 합니다:

> const prettier = require('prettier')
> const print = prettier.doc.printer.printDocToString
> const { concat, group, join, line, softline } = prettier.doc.builders

그러면 당신은 다음과 같은 결과물을 볼 수 있습니다.

> print(concat(['foo', 'bar', 'baz']), {})
{ formatted: 'foobarbaz' }
> print(join('|', ['foo', 'bar', 'baz']), {})
{ formatted: 'foo|bar|baz' }

group 과 비슷하게 테스트를 하기 위해서는 printWidth 속성을 추가해야만 합니다.

> print(group(join(line, ['foo', 'bar', 'baz', 'qux'])), { printWidth: 20 })
{ formatted: 'foo bar baz qux' }
> print(group(join(line, ['foo', 'bar', 'baz', 'qux'])), { printWidth: 10 })
{ formatted: 'foo\nbar\nbaz\nqux' }
> print(group(join(softline, ['foo', 'bar', 'baz', 'qux'])), { printWidth: 20 })
{ formatted: 'foobarbazqux' }
> print(group(join(softline, ['foo', 'bar', 'baz', 'qux'])), { printWidth: 10 })
{ formatted: 'foo\nbar\nbaz\nqux' }

이 방법으로 배울 수 있습니다. 사용자 경험이 그리 좋지 않다는 것을 알고 있으며, 더 나은 것이 있으면 좋겠습니다(예를 들어, 이런 표현을 실행하고 다양한 입력으로 결과를 볼 수 있는 웹 플레이그라운드가 있다면 어떨까요?), 하지만 저는 더 나은 방법을 알지 못합니다.

Pending things

만약 해당 example을 실행시킨다면, 당신은 다음과 동일한 TOML 출력을 확인할 수 있습니다.

> prettier-plugin-toml@0.0.1 example /home/fvictorio/repos/prettier-plugin-toml
> prettier --plugin . example.toml
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T15:32:00.000Z
[database]
server = "192.168.1.1"
ports = [8001, 8001, 8002]
connection_max = 5000
enabled = true
[servers]
[servers.alpha]
ip = "10.0.0.1"
dc = "eqdc10"
[servers.beta]
ip = "10.0.0.2"
dc = "eqdc10"
[clients]
data = [["gamma", "delta"], [1, 2]]
hosts = ["alpha", "omega"]

이것이 Prettier보다 나은 방법이라고 주장하기는 어렵습니다. 우리가 사용하는 파서로 쉽게 할 수 없는 두 가지 매우 중요한 일이 있습니다:

  1. 빈 줄 보존: Prettier의 철학은 빈 줄을 유지하는 것입니다(비록 두 개 이상의 빈 줄이 있으면 하나의 빈 줄로 대체됩니다). 이것은 가능하지만, 이를 위해서는 노드의 시작과 끝 인덱스를 쉽게 얻을 수 있는 방법이 필요합니다. 노드 예시에서 보듯이, 우리는 시작 줄과 열만 가지고 있습니다.
  2. 테이블 들여쓰기: AST의 표현이 적절한 트리가 아닌 각 줄마다 노드의 리스트를 가지고 있다는 것을 기억하세요. 테이블 객체 아래에 "자식" 키가 있다면, path.map(print, 'children')과 같은 것을 하고, 그것을 하드라인으로 조인하고 들여쓸 수 있을 것입니다.

다음 단계는 무엇일까요?
이제 여러분은 자신만의 플러그인을 시작하거나 기존 플러그인에 기여할 만큼 충분히 배웠기를 바랍니다. 플러그인 목록을 살펴보세요: 원하는 언어가 아직 Prettier로 예쁘게 만들어지지 않았다면, 여러분이 직접 만들 수 있습니다! 그 언어가 이미 있다면, 기여할 수 있습니다.

Prettier 플러그인의 좋은 점은 TDD(Test-Driven Development)로 작업하기 매우 쉽다는 것입니다. 플러그인에 기여하고 싶다면, 작동하지 않는 예제를 가진 픽스처를 추가하고 모든 테스트를 통과하도록 시도하세요. 새로운 플러그인을 만들고 있다면 작게 시작할 수 있습니다: 문법의 일부분을 사용한 간단한 예제로 테스트를 추가하고 이를 Prettier로 만드세요!

0개의 댓글