[T.Viewer 개발일기] 10. regex 정규식 검색 나의 경험담 2020. 7. 30. 22:37

Android logcat이나 VS Code의 검색창을 보면 Regex 체크박스가 있다. 우리도 지원해 줘야 할 것 같아서, 지난번에 체크 박스는 넣어놨는데. 로직은 어떻게 하지? 아, VS Code랑 똑같이 하믄 되겠다! 흐흐 오픈소스 묻어가기.

VS Code에서 정규식 검색

VS Code의 로직 엿보기

유명한 오픈소스 프로젝트 답게 소스 정리가 아주 잘 되어 있다. 쉽게 찾을 수 있었다.

FindModelBoundToEditorModel class 에서, private research 메소드가 호출될 때 _findMatches에서 editor 인테페이스의 findMatches 메소드를 호출 함으로써 매치되는 Range Array를 리턴받는다.

  /**
    * Search the model.
    * @param searchString The string used to search. If it is a regular expression, set `isRegex` to true.
    * @param searchScope Limit the searching to only search inside this range.
    * @param isRegex Used to indicate that `searchString` is a regular expression.
    * @param matchCase Force the matching to match lower/upper case exactly.
    * @param wordSeparators Force the matching to match entire words only. Pass null otherwise.
    * @param captureMatches The result will contain the captured groups.
    * @param limitResultCount Limit the number of results
    * @return The ranges where the matches are. It is empty if no matches have been found.
    */
  findMatches(searchString: string, searchScope: IRange, isRegex: boolean, matchCase: boolean, wordSeparators: string | null, captureMatches: boolean, limitResultCount?: number): FindMatch[];

내가 필요한 것은 구현체의 로직인데, textModel.ts에서 찾을 수 있다. regex 일때, 로그 한 줄에 매치되는 단어가 있는지만 확인하면 되므로 범위를 더 좁힐 수 있다.

  public findMatches(searchString: string, rawSearchScope: any, isRegex: boolean, matchCase: boolean, wordSeparators: string | null, captureMatches: boolean, limitResultCount: number = LIMIT_FIND_COUNT): model.FindMatch[] {
    this._assertNotDisposed();

    let searchRange: Range;
    if (Range.isIRange(rawSearchScope)) {
      searchRange = this.validateRange(rawSearchScope);
    } else {
      searchRange = this.getFullModelRange();
    }

    if (!isRegex && searchString.indexOf('\n') < 0) {
      // not regex, not multi line
      const searchParams = new SearchParams(searchString, isRegex, matchCase, wordSeparators);
      const searchData = searchParams.parseSearchRequest();

      if (!searchData) {
        return [];
      }

      return this.findMatchesLineByLine(searchRange, searchData, captureMatches, limitResultCount);
    }

    return TextModelSearch.findMatches(this, new SearchParams(searchString, isRegex, matchCase, wordSeparators), searchRange, captureMatches, limitResultCount);
  }

textModelSearch.tsfindMatches가 나에게 필요한 부분인데, 검색 문자열의 전처리를 먼저 해야 한다. 할게 많구나.

  public static findMatches(model: TextModel, searchParams: SearchParams, searchRange: Range, captureMatches: boolean, limitResultCount: number): FindMatch[] {
    const searchData = searchParams.parseSearchRequest();
    if (!searchData) {
      return [];
    }

    if (searchData.regex.multiline) {
      return this._doFindMatchesMultiline(model, searchRange, new Searcher(searchData.wordSeparators, searchData.regex), captureMatches, limitResultCount);
    }
    return this._doFindMatchesLineByLine(model, searchRange, searchData, captureMatches, limitResultCount);
  }

parseSearchRequest 에서 전처리를 하는데, 각종 파라미터들과 함께 다시 strings.createRegExp를 통해 비교 정규식을 만들어 내는고 있었다.

  public parseSearchRequest(): SearchData | null {
    if (this.searchString === '') {
      return null;
    }

    // Try to create a RegExp out of the params
    let multiline: boolean;
    if (this.isRegex) {
      multiline = isMultilineRegexSource(this.searchString);
    } else {
      multiline = (this.searchString.indexOf('\n') >= 0);
    }

    let regex: RegExp | null = null;
    try {
      regex = strings.createRegExp(this.searchString, this.isRegex, {
        matchCase: this.matchCase,
        wholeWord: false,
        multiline: multiline,
        global: true,
        unicode: true
      });
    } catch (err) {
      return null;
    }

    if (!regex) {
      return null;
    }

    let canUseSimpleSearch = (!this.isRegex && !multiline);
    if (canUseSimpleSearch && this.searchString.toLowerCase() !== this.searchString.toUpperCase()) {
      // casing might make a difference
      canUseSimpleSearch = this.matchCase;
    }

    return new SearchData(regex, this.wordSeparators ? getMapForWordSeparators(this.wordSeparators) : null, canUseSimpleSearch ? this.searchString : null);
  }

createRegExp는 아래와 같고, RegExp를 생성해서 리턴한다.

export function createRegExp(searchString: string, isRegex: boolean, options: RegExpOptions = {}): RegExp {
  if (!searchString) {
    throw new Error('Cannot create regex from empty string');
  }
  if (!isRegex) {
    searchString = escapeRegExpCharacters(searchString);
  }
  if (options.wholeWord) {
    if (!/\B/.test(searchString.charAt(0))) {
      searchString = '\\b' + searchString;
    }
    if (!/\B/.test(searchString.charAt(searchString.length - 1))) {
      searchString = searchString + '\\b';
    }
  }
  let modifiers = '';
  if (options.global) {
    modifiers += 'g';
  }
  if (!options.matchCase) {
    modifiers += 'i';
  }
  if (options.multiline) {
    modifiers += 'm';
  }
  if (options.unicode) {
    modifiers += 'u';
  }

  return new RegExp(searchString, modifiers);
}

이제 다시 스택을 거슬러 올라가서, 생성된 정규식이 실질적으로 사용되는 _doFindMatchesLineByLine을 살펴보자. 주석까지 잘 달려있는데, 내가 필요한건 라인 한 줄에 대한 평가니까 _findMatchesInLine만 있으면 된다.

  private static _doFindMatchesLineByLine(model: TextModel, searchRange: Range, searchData: SearchData, captureMatches: boolean, limitResultCount: number): FindMatch[] {
    const result: FindMatch[] = [];
    let resultLen = 0;

    // Early case for a search range that starts & stops on the same line number
    if (searchRange.startLineNumber === searchRange.endLineNumber) {
      const text = model.getLineContent(searchRange.startLineNumber).substring(searchRange.startColumn - 1, searchRange.endColumn - 1);
      resultLen = this._findMatchesInLine(searchData, text, searchRange.startLineNumber, searchRange.startColumn - 1, resultLen, result, captureMatches, limitResultCount);
      return result;
    }

    // Collect results from first line
    const text = model.getLineContent(searchRange.startLineNumber).substring(searchRange.startColumn - 1);
    resultLen = this._findMatchesInLine(searchData, text, searchRange.startLineNumber, searchRange.startColumn - 1, resultLen, result, captureMatches, limitResultCount);

    // Collect results from middle lines
    for (let lineNumber = searchRange.startLineNumber + 1; lineNumber < searchRange.endLineNumber && resultLen < limitResultCount; lineNumber++) {
      resultLen = this._findMatchesInLine(searchData, model.getLineContent(lineNumber), lineNumber, 0, resultLen, result, captureMatches, limitResultCount);
    }

    // Collect results from last line
    if (resultLen < limitResultCount) {
      const text = model.getLineContent(searchRange.endLineNumber).substring(0, searchRange.endColumn - 1);
      resultLen = this._findMatchesInLine(searchData, text, searchRange.endLineNumber, 0, resultLen, result, captureMatches, limitResultCount);
    }

    return result;
  }

이제 정규식이랑 매치되는 부분이 나올것 같은데도 안나오는뎈ㅋㅋ 이게 전체 editor에서 검색이라 로직이 훨씬 복잡하다. Searchernext메소드에서 정규식과 매치되는 부분을 찾고, createFindMatch을 통해서 매치되는 부분의 위치를 결과 값으로 정리하는 것으로 마무리가 된다.

	private static _findMatchesInLine(searchData: SearchData, text: string, lineNumber: number, deltaOffset: number, resultLen: number, result: FindMatch[], captureMatches: boolean, limitResultCount: number): number {
		const wordSeparators = searchData.wordSeparators;
		if (!captureMatches && searchData.simpleSearch) {
			const searchString = searchData.simpleSearch;
			const searchStringLen = searchString.length;
			const textLength = text.length;

			let lastMatchIndex = -searchStringLen;
			while ((lastMatchIndex = text.indexOf(searchString, lastMatchIndex + searchStringLen)) !== -1) {
				if (!wordSeparators || isValidMatch(wordSeparators, text, textLength, lastMatchIndex, searchStringLen)) {
					result[resultLen++] = new FindMatch(new Range(lineNumber, lastMatchIndex + 1 + deltaOffset, lineNumber, lastMatchIndex + 1 + searchStringLen + deltaOffset), null);
					if (resultLen >= limitResultCount) {
						return resultLen;
					}
				}
			}
			return resultLen;
		}

		const searcher = new Searcher(searchData.wordSeparators, searchData.regex);
		let m: RegExpExecArray | null;
		// Reset regex to search from the beginning
		searcher.reset(0);
		do {
			m = searcher.next(text);
			if (m) {
				result[resultLen++] = createFindMatch(new Range(lineNumber, m.index + 1 + deltaOffset, lineNumber, m.index + 1 + m[0].length + deltaOffset), m, captureMatches);
				if (resultLen >= limitResultCount) {
					return resultLen;
				}
			}
		} while (m);
		return resultLen;
	}

최종적으로 RegExpexec 메소드를 사용해서 매치되는 부분은 next 메소드 안에서 확인 할 수 있다.

  public next(text: string): RegExpExecArray | null {
    const textLength = text.length;

    let m: RegExpExecArray | null;
    do {
      if (this._prevMatchStartIndex + this._prevMatchLength === textLength) {
        // Reached the end of the line
        return null;
      }

      m = this._searchRegex.exec(text);
      if (!m) {
        return null;
      }

T.Viewer에 적용하기

생각보다 되게 길게 VS Code의 코드를 설명했는데 간단하게 정리해보면, 필요한 것은 매우 간단하다.

  1. 전처리를 통해서 정규식을 확정한다.
  2. 정규식으로 비교한다.

전처리라고 해서 대단한 것은 없고, unicode 설정만 추가해주면 된다.

regex 체크박스 상태가 변경되었을 때, RegExp 인스턴트를 생성해 두고, 필터에서 바로 사용하도록 했다.

    onChangeTagRegex: function () {
      if (this.tagRegexSetting)
        this.tagRegex = new RegExp(this.tagFilter, 'u');
    },
    onChangeMessageRegex: function () {
      if (this.messageRegexSetting)
        this.messageRegex = new RegExp(this.messageFilter, 'u');
    },
    filterMessage: function (line) {
      if (this.messageFilter == '')
        return true;

      if (!this.messageRegexSetting)
        return -1 != line.indexOf(this.messageFilter);

      return this.messageRegex.test(line);
    },

이런 로직이 제대로 동작하는지 확인할 때 단위 테스트가 정말 유용하다. 여러가지 상황별 테스트를 쉽고 빠르게 적용해 볼 수 있기 때문이다. 간단하게 정규식이 제대로 적용되는지 테스트를 작성해보았다.

    it('should filterTag return correct value when tagRegexSetting is true', () => {
      const wrapper = mount(LogMonitor, {
        localVue,
        vuetify,
      })
      const msgTagBanana = "07-10 14:51:21.337+0900 V/Banana( 2617): heart-battery.c banana";
      const msgTagBar = "07-10 14:51:21.337+0900 F/Bar( 2617): heart-battery.c orange";
      const msgTagMangoBarSpace = "07-10 14:51:21.337+0900 F/MangoBar   ( 2617): heart-battery.c orange";
      const vm = wrapper.vm;
      vm.tagFilter = '^Ba';
      vm.tagRegexSetting = true;
      vm.onChangeTagRegex();
      expect(vm.filterTag(msgTagBanana)).toEqual(true);
      expect(vm.filterTag(msgTagBar)).toEqual(true);
      expect(vm.filterTag(msgTagMangoBarSpace)).toEqual(false);

      vm.tagFilter = '.*Bar';
      vm.tagRegexSetting = true;
      vm.onChangeTagRegex();
      expect(vm.filterTag(msgTagBanana)).toEqual(false);
      expect(vm.filterTag(msgTagBar)).toEqual(true);
      expect(vm.filterTag(msgTagMangoBarSpace)).toEqual(true);

      vm.tagFilter = 'Bar$';
      vm.tagRegexSetting = true;
      vm.onChangeTagRegex();
      expect(vm.filterTag(msgTagBanana)).toEqual(false);
      expect(vm.filterTag(msgTagBar)).toEqual(true);
      expect(vm.filterTag(msgTagMangoBarSpace)).toEqual(true);
    })

정작 적용된 코드는 몇 줄 안됨. 뭥미. VS Code 관광한 셈 치자.ㅋ

마치며...

정규식 적용은 생각보다 어렵지 않았다. VS Code의 소스를 보면서 좋은 소스 코드의 구조에 대해서 한 번 더 생각해 볼 수 있어서 좋았다.

msaltnet/T.Viewer - Apply regular expression feature RegExp-MDN

댓글