`Code.Fragment`

Code.Fragment

텍스트로 된 코드 조각(fragment)을 분석하고 가능할 때마다 유용한 정보를 추출하는 편의 기능들을 제공하는 모듈이에요.

이 모듈은 실험적(experimental)으로 간주해야 합니다.

출처: Code.Fragment

본문

에디터에서 코드 자동완성이나 툴팁 같은 기능을 만들다 보면, "완성되지 않은 채 입력 중인 코드"를 분석해야 할 때가 있어요. 사용자가 막 절반을 타이핑한 상태라서 문법적으로 완전한 코드가 아니죠. Code.Fragment 모듈은 바로 이런 상황에서 쓰라고 만들어진 도구예요. 입력 중인 코드 조각을 받아서, 그 안에 담길 수 있는 정보를 최대한 뽑아내 줍니다.

핵심 함수는 세 가지예요. container_cursor_to_quoted/2는 커서 위치를 AST로 바꿔주고, cursor_context/2는 커서가 무엇을 의미하는지 문맥을 알려주며, surround_context/3는 커서를 둘러싼 식별자의 시작과 끝 위치를 돌려주죠.

container_cursor_to_quoted/2 — 커서를 포함한 AST로

@spec container_cursor_to_quoted(List.Chars.t(), container_cursor_to_quoted_opts()) ::
  {:ok, Macro.t()}
  | {:error, {location :: keyword(), binary() | {binary(), binary()}, binary()}}

이 함수는 Elixir 코드 조각(커서 위치를 나타내는 문자열)을 받아, 커서 위치를 나타내는 특별한 __cursor__() 노드를 부모(container) 안에 포함한 AST로 변환해요. 예를 들어 다음 코드가 입력으로 주어지면:

max(some_value,

이 함수는 다음과 같은 AST를 반환합니다:

max(some_value, __cursor__())

즉 열린 괄호를 닫고 커서 위치를 넣을 수 있어요. 커서 위치에 있으면서 부모가 아닌 다른 내용은 버려집니다. 예를 들어 이렇게 입력하면:

max(some_value, another_val

여전히 같은 AST를 반환합니다:

max(some_value, __cursor__())

마찬가지로 이것만 주어져도:

max(some_va

이렇게 반환해요:

max(__cursor__())

괄호가 없는 호출도 지원합니다. 괄호가 암시된다고 가정하기 때문이에요.

튜플, 리스트, 맵, 이진(binary) 모두 커서 위치를 유지합니다:

max(some_value, [1, 2,

다음 AST를 반환하죠:

max(some_value, [1, 2, __cursor__()])

키워드 목록(과 do-end 블록)도 유지됩니다. 다음 입력들이:

if(some_value, do:
if(some_value, do: :token
if(some_value, do: 1 + val

모두 이렇게 반환합니다:

if(some_value, do: __cursor__())

다중 줄 블록에서는 이전 줄들이 모두 보존됩니다.

이 함수가 반환한 AST는 평가하기엔 안전하지 않지만, 분석하고 확장할 수는 있어요.

예시를 몇 개 볼게요. 함수 호출:

iex> Code.Fragment.container_cursor_to_quoted("max(some_value, ")
{:ok, {:max, [line: 1], [{:some_value, [line: 1], nil}, {:__cursor__, [line: 1], []}]}}

컨테이너(리스트):

iex> Code.Fragment.container_cursor_to_quoted("[some, value")
{:ok, [{:some, [line: 1], nil}, {:__cursor__, [line: 1], []}]}

표현식이 완전하면 전체 표현식은 버려지고 부모만 반환됩니다:

iex> Code.Fragment.container_cursor_to_quoted("if(is_atom(var)")
{:ok, {:if, [line: 1], [{:__cursor__, [line: 1], []}]}}

즉 완전한 표현식 자체는 커서만 반환합니다:

iex> Code.Fragment.container_cursor_to_quoted("if(is_atom(var))")
{:ok, {:__cursor__, [line: 1], []}}

연산자도 Elixir v1.15부터 포함됩니다:

iex> Code.Fragment.container_cursor_to_quoted("foo +")
{:ok, {:+, [line: 1], [{:foo, [line: 1], nil}, {:__cursor__, [line: 1], []}]}}

->의 왼쪽을 제대로 파싱하려면(이것은 익명 함수와 do-end 블록 양쪽에 나타남), 나머지 내용을 trailing_fragment 옵션으로 주어야 합니다:

iex> Code.Fragment.container_cursor_to_quoted("fn x", trailing_fragment: " -> :ok end")
{:ok, {:fn, [line: 1], [{:->, [line: 1], [[{:__cursor__, [line: 1], []}], :ok]}]}}

옵션은 다음과 같아요:

  • :file — 파싱 오류가 있을 때 보고할 파일명. 기본값은 "nofile".
  • :line — 파싱할 문자열의 시작 줄. 기본값 1.
  • :column — 파싱할 문자열의 시작 열. 기본값 1.
  • :columnstrue이면 quoted 메타데이터에 :column 키를 붙임. 기본값 false.
  • :token_metadatatrue이면 표현식 AST에 토큰 관련 메타데이터를 포함. do·end 토큰, 닫는 토큰, 표현식의 끝, sigil의 구분자 등에 대한 메타데이터예요. t:Macro.metadata/0 참고. 기본값 false.
  • :literal_encoder — AST의 리터럴을 인코딩하는 함수. Code.string_to_quoted/2의 문서를 참고하세요.
  • :trailing_fragment (v1.18.0부터) — 커서 뒤의 나머지 내용. 익명 함수와 ->의 왼쪽을 올바르게 완성하는 데 필요.
  • :preserve_sigils (v1.20.0부터) — sigil 커서 위치를 보존(아래 "sigil 추적" 참고).

sigil 추적하기

:preserve_sigils 옵션으로 sigil 안의 커서 위치를 추적할 수 있어요.

sigil이 갑자기 끝나면, sigil_* 호출의 두 번째 인자로 커서가 오게 됩니다:

iex> Code.Fragment.container_cursor_to_quoted("~r/foo", preserve_sigils: true)
{:ok,
{:sigil_r, [delimiter: "/", line: 1],
  [{:<<>>, [line: 1], ["foo"]}, {:__cursor__, [line: 1, column: 7], []}]}}

sigil이 완성되고 수식자(modifier)가 0개 이상 있으면, 이전 구분자들이 모두 명시된 채 커서가 리스트 안에 중첩됩니다:

iex> Code.Fragment.container_cursor_to_quoted("~r/foo/i", preserve_sigils: true)
{:ok,
 {:sigil_r, [delimiter: "/", line: 1],
  [{:<<>>, [line: 1], ["foo"]}, [105, {:__cursor__, [line: 1, column: 9], []}]]}}

커서가 sigil 뒤에 있으면 다른 모든 것과 마찬가지로 버려집니다:

iex> Code.Fragment.container_cursor_to_quoted("~r/foo/i ", preserve_sigils: true)
{:ok, {:__cursor__, [line: 1], []}}

cursor_context/2 — 커서 문맥 파악

@spec cursor_context(List.Chars.t(), cursor_opts()) ::
  {:alias, charlist()}
  | {:alias, inside_alias, charlist()}
  | {:block_keyword_or_binary_operator, charlist()}
  | {:dot, inside_dot, charlist()}
  | {:dot_arity, inside_dot, charlist()}
  | {:dot_call, inside_dot, charlist()}
  | :expr
  | {:local_or_var, charlist()}
  | {:local_arity, charlist()}
  | {:local_call, charlist()}
  | {:anonymous_call, inside_caller}
  | {:capture_arg, charlist()}
  | {:module_attribute, charlist()}
  | {:operator, charlist()}
  | {:operator_arity, charlist()}
  | {:operator_call, charlist()}
  | :none
  | {:sigil, charlist()}
  | {:struct, inside_struct}
  | {:unquoted_atom, charlist()}
when ...

이 함수는 Elixir 코드 조각(커서 위치를 나타내는 문자열)을 받아, 그 문자열을 기반으로 가장 최근 토큰에 대한 문맥 정보를 제공해요. 이 함수의 반환값은 팁(tips), 제안(suggestions), 자동완성 기능을 제공하는 데 사용할 수 있습니다.

이 함수는 토큰을 대상으로 분석을 수행해요. 즉 구성 요소들이 서로 어떻게 중첩되는지는 이해하지 못합니다. 아래 "제한 사항" 섹션을 참고하세요.

새 릴리즈에서 새 커서 정보가 추가될 수 있으므로, 이 함수의 반환 타입을 처리할 때 catch-all 절을 추가하는 것을 고려하세요.

예시:

iex> Code.Fragment.cursor_context("")
:expr

iex> Code.Fragment.cursor_context("hello_wor")
{:local_or_var, ~c"hello_wor"}

반환값의 종류는 다양해요. 주요 몇 가지를 정리하면:

  • {:alias, charlist} — 문맥이 alias(중첩일 수 있음). 예: Hello.Wor 또는 HelloWor
  • {:alias, inside_alias, charlist}inside_alias{:module_attribute, charlist} 또는 {:local_or_var, charlist} 표현식인 alias. 예: __MODULE__.Submodule 또는 @hello.Submodule
  • {:block_keyword_or_binary_operator, charlist} — 블록 키워드(do, end, after, catch, else, rescue) 또는 이진(binary) 연산자일 수 있음
  • {:dot, inside_dot, charlist} — 점(dot) 문맥. inside_dot{:var, charlist}, {:alias, charlist}, {:module_attribute, charlist}, {:unquoted_atom, charlist} 또는 점 자신. 변수가 주어지면 원격 호출 또는 맵 필드 접근일 수 있음. 예: Hello.wor, :hello.wor, hello.wor, Hello.nested.wor, hello.nested.wor, @hello.world
  • {:dot_arity, inside_dot, charlist} — 점 arity 문맥. 예: Hello.world/, :hello.world/, hello.world/2, @hello.world/2
  • {:dot_call, inside_dot, charlist} — 점 호출 문맥. 표현식 뒤에 괄호나 공백이 추가됨. 예: Hello.world(, :hello.world(, Hello.world , hello.world(, hello.world , @hello.world(
  • :expr — 어떤 표현식이든 가능. 자동완성이 alias, 로컬 또는 변수를 제안할 수 있음
  • {:local_or_var, charlist} — 변수 또는 로컬(import 또는 local) 호출 문맥. 예: hello_wor
  • {:local_arity, charlist} — 로컬(import 또는 local) arity 문맥. 예: hello_world/
  • {:local_call, charlist} — 로컬(import 또는 local) 호출 문맥. 예: hello_world(hello_world
  • {:anonymous_call, inside_caller} — 익명 호출 문맥. 예: fun.(@fun.(
  • {:module_attribute, charlist} — 모듈 속성 문맥. 예: @hello_wor
  • {:operator, charlist} — 연산자 문맥. 예: + 또는 ==. when 같은 텍스트 연산자는 연산자로 나타나지 않고 :local_or_var로 나타나요. @는 결코 :operator가 아니고 항상 :module_attribute
  • {:operator_arity, charlist} — 연산자 arity 문맥. 연산자 뒤에 /가 붙은 것. 예: +/, not/ 또는 when/
  • {:operator_call, charlist} — 연산자 호출 문맥. 연산자 뒤에 공백이 붙은 것. 예: left + , not 또는 x when
  • :none — 가능한 문맥 없음
  • {:sigil, charlist} — sigil 문맥. ~~s 같은 sigil의 시작이거나, ~>·~>>처럼 ~로 시작하는 연산자일 수 있음
  • {:struct, inside_struct} — 구조체 문맥. 예: %, %UR 또는 %URI
  • {:unquoted_atom, charlist} — 따옴표 없는 atom 문맥. 어떤 atom 또는 모듈을 나타내는 atom일 수 있음

완전한 예시 목록은 이 함수의 테스트 스위트를 살펴보는 것을 권장합니다.

제한 사항

분석은 현재 토큰에 기반하며, 입력의 마지막 줄을 분석해요. 예를 들어 이 코드는:

iex> Code.Fragment.cursor_context("%URI{")
:expr

:expr을 반환하는데, 이는 어떤 변수, 로컬 함수, alias도 사용될 수 있다는 뜻이에요. 하지만 우리가 구조체 안에 있으므로, 최선의 제안은 구조체 필드일 거예요. 그런 경우 container_cursor_to_quoted를 사용할 수 있는데, 이 함수는 커서가 현재 속한 AST의 컨테이너를 반환합니다. 그 AST를 분석해 필드 이름 완성을 제공할 수 있어요.

토큰 기반 구현의 결과로, 이 함수는 입력의 마지막 줄만 고려합니다. 즉 문자열, heredoc 등 안에서도 제안을 보여주는데, 이는 doctest나 참조 등에 도움이 되도록 의도된 것입니다.

surround_context/3 — 커서 주변 식별자 위치

@spec surround_context(List.Chars.t(), position(), cursor_opts()) ::
  %{begin: position(), end: position(), context: context} | :none
when context: ...

이 함수는 Elixir 코드 조각과 position을 받아, 식별자의 시작과 끝을 담은 맵을 그 문맥과 함께 반환하고, 알려진 문맥이 없으면 :none을 반환해요. 이는 에디터에서 마우스 오버(호버)와 하이라이트 기능을 제공하는 데 유용합니다.

cursor_context/2surround_context/3의 차이는, 전자는 코드 조각의 표현식이 불완전하다고 가정하는 반면, 후자는 표현식이 완전하다고 가정한다는 점이에요. 예를 들어 cursor_context/2do는 키워드일 수도, 변수일 수도, 로컬 호출일 수도 있지만, surround_context/3에서는 do가 항상 키워드입니다.

positionlinecolumn을 모두 담는데, 둘 다 1부터 시작해요. column은 둘러싼 표현식 앞에 위치해야 합니다. 예를 들어 foo 표현식은 column 1, 2, 3에 대해 뭔가를 반환하지만 4에 대해서는 반환하지 않아요:

foo
^ column 1

foo
 ^ column 2

foo
  ^ column 3

foo
   ^ column 4

반환된 맵은 표현식이 시작하는 column과, 표현식이 끝난 직후의 첫 column을 담습니다.

cursor_context/2와 유사하게 이 함수도 토큰 기반이며 모든 상황에서 정확하지 않을 수 있어요. 자세한 내용은 cursor_context/2의 "Return values"와 "Limitations" 섹션을 참고하세요.

예시:

iex> Code.Fragment.surround_context("foo", {1, 1})
%{begin: {1, 1}, context: {:local_or_var, ~c"foo"}, end: {1, 4}}

cursor_context/2와의 차이

surround_context/3는 복잡한 표현식을 포착하려 하기 때문에 cursor_context/2와 몇 가지 차이가 있어요:

  • dot_call/dot_arityoperator_call/operator_arity는 그 사이에 의미 있는 구분이 없으므로 각각 dotoperator 문맥으로 축약됨
  • 반면 이 함수는 local_call/local_aritylocal_or_var 사이에는 여전히 구분을 둠. 후자는 로컬이나 변수일 수 있기 때문
  • 식별자 뒤에 따르는 @가 없으면 {:operator, ~c"@"}로 반환됨(cursor_context/2{:module_attribute, ~c""}와 대조적)
  • 이 함수는 빈 sigil {:sigil, ~c""}이나 빈 구조체 {:struct, ~c""}를 문맥으로 결코 반환하지 않음
  • 이 함수는 키워드를 {:keyword, ~c"do"}로 반환함
  • 이 함수는 결코 :expr을 반환하지 않음

완전한 예시 목록은 이 함수의 테스트 스위트를 참고하세요.

lines/1 — 줄 나누기

iex> Code.Fragment.lines("foo\r\nbar\r\nbaz")
["foo\r\n", "bar\r\n", "baz"]

iex> Code.Fragment.lines("foo\nbar\nbaz")
["foo\n", "bar\n", "baz"]

iex> Code.Fragment.lines("")
[""]

주어진 문자열의 줄 목록을 줄 끝(line ending)을 보존한 채 반환해요. Elixir 컴파일러가 인식하는 줄 끝만 고려합니다. 즉 \r\n\n이에요. 줄 끝 없이 줄을 얻고 싶다면 String.split(string, ["\r\n", "\n"])을 사용하세요. (v1.19.0부터)

더 알아보기

  • 코드 조각 분석의 기반이 되는 AST 개념은 Code 모듈 문서에서 다룹니다.
  • Code.string_to_quoted/2를 참고하면 :literal_encoder 등 옵션의 의미를 더 자세히 알 수 있어요.
  • 이 모듈의 완전한 API 목록은 elixir hexdocs의 api-reference에서 확인하세요.