편집 기능 볶음짬뽕


요약

이전 글에서 소개했던 아무말 생성기의 문제 중 하나는 생성물이 단순한 텍스트 형식이기 때문에 줄넘김 같은게 전혀 없이 한줄로 쭉 생성되고, 편하게 보려면 그걸 다시 재정렬(이를테면 vim에서 gq)해 줘야 한다는 점이었다.

생성된 결과물에 formatter를 돌린 결과물을 받아볼 수 있을까?

데모

pub trait EditAndFormat {
    fn edit_and_format(&self, edit: TextEdit) -> TextEdit;
}

fn apply_edits<T: IncrementalSync + LspAdapter>(
    init: T,
    edits: impl AsRef<[TextEdit]>,
) -> T {
    edits.as_ref().iter().fold(init, |acc, edit| {
        let start_byte = acc.position_to_offset(&edit.range.start).unwrap();
        let end_byte = acc.position_to_offset(&edit.range.end).unwrap();
        acc.with_change(start_byte..end_byte, &edit.new_text)
    })
}

impl EditAndFormat for TreesittingDocument {
    fn edit_and_format(&self, edit: TextEdit) -> TextEdit {
        let start_byte = self.position_to_offset(&edit.range.start).unwrap();
        let end_byte = self.position_to_offset(&edit.range.end).unwrap();
        let rest_size = self.rope().len_bytes() - end_byte;
        let new_end_byte = start_byte + edit.new_text.len();
        let updated = self.with_change(start_byte..end_byte, &edit.new_text);

        let Some(edits) = updated.format(start_byte..new_end_byte) else {
            // No further changes from formatting, just return the edit
            return edit;
        };

        let updated2 = apply_edits(updated, edits);
        let diff_from_source = updated2
            .rope()
            .byte_slice(start_byte..(updated2.rope().len_bytes() - rest_size))
            .to_string();
        TextEdit { range: edit.range, new_text: diff_from_source }
    }
}

내부적으로 정리를 많이 해야 했는데, 정리하고 나니 좀 간단한 것 같기도… 하여튼 설명하자면 기존 코드는 변경을 적용할 때 기존 객체가 없어지는 방식으로 구현되어 있었다.

pub trait IncrementalSync
where
    Self: Sized,
{
    /// Update self with the given change applied.
    fn apply_change<R: RangeBounds<usize>, S: AsRef<str>>(
        self, // self get consumed
        byte_range: R,
        text: S,
    ) -> Self;
}

근데, 내가 하려는 건 생성된 결과물에 대해 format 기능을 실행해서 그 결과물을 얻어야 하는 것이라, 원본 문서와 중간 문서를 모두 가지고 있어야 한다. 따라서 기존 객체를 그대로 두고 새 복제본을 생성해야 하는건데… 쉽게 말하면 위에서 self&self로 바꿔야 한다.

pub trait IncrementalSync2
where
    Self: Sized,
{
    /// Creates a new Self having the change applied.
    fn with_change<R: RangeBounds<usize>, S: AsRef<str>>(
        &self, // self get referenced
        byte_range: R,
        text: S,
    ) -> Self;
}

다행히 내부에서 쓰는 데이터 타입들이 Clone을 큰 비용 없이 Copy-on-write 비스무레하게 지원해서 큰 성능 낭비 없이 구현할 수 있었다.

그 뒷부분은 간단하다. 그냥 as-if로 format 적용하고, 최초 문서에서 달라지는 부분을 떼어내서 최종 변경사항을 만들어내는 방식.

결론

덕지덕지 요구사항에 맞춰 몸비틀기하는 프로젝트지만 어쨌든 쓸만하다.