DSL для регулярных выражений на Kotlin

  • Tutorial


Всем привет!


Эта статья про реализацию одного конкретного DSL (domain specific language, предметно-ориентированный язык) для регулярных выражений средствами Kotlin, но при этом она вполне может дать общее представление, о том, как написать свой DSL на Kotlin и что обычно будет делать "под капотом" любой другой DSL, использующий те же возможности языка.


Многие уже используют Kotlin или хотя бы пробовали это делать, да и остальные вполне могли слышать о том, что Kotlin располагает к написанию изящных DSL, чему есть блестящие примеры — Anko и kotlinx.html.


Конечно же, для регулярных выражений подобное уже делали (и ещё: на Java, на Scala, на C# — реализаций много, похоже, это распространённое развлечение). Но если хочется попрактиковаться или попробовать DSL-ориентированные языковые возможности Kotlin, то добро пожаловать под кат.


Как обычно выглядит DSL, написанный на Kotlin?



В худшем случае, наверное, так.


Большинство DSL на Java предлагают использовать цепочки вызовов для своих конструкций, как в этом примере Java Regex DSL:


Regex regex = RegexBuilder.create()
    .group("#timestamp")
        .number("#hour").constant(":")
        .number("#min").constant(":")
        .number("#secs").constant(":")
        .number("#ms")
    .end()
    .build();

Мы могли бы использовать этот подход, но он имеет ряд неудобств, среди которых можно сразу отметить два:


  • неудобная реализация вложенных конструкций (group и end выше), из-за которых придётся ещё и воевать с форматтером, да и банальной проверки соответствия открывающих и закрывающих элементов нет, можно написать лишний .end();


  • плохие возможности для динамического формирования выражения: если мы хотим выполнить произвольный код перед добавлением очередной части в запрос — например, проверить условие — нам нужно будет разрывать цепочку вызовов и хранить частично созданное выражение в переменной.

С этими недостатками в Kotlin можно справиться, если реализовать DSL в стиле Type-Safe Groovy-Style Builder (при объяснении технических деталей эта статья будет во многом повторять страницу документации по ссылке). Тогда выглядеть код на нём будет подобно этому примеру Anko:


Показать код
verticalLayout {
    val name = editText()
    button("Say Hello") {
        onClick { toast("Hello, ${name.text}!") }
    }
}

Или этому примеру kotlinx.html:


Показать код
html {
    body {
        div {
            a("http://kotlinlang.org") {
                target = ATarget.blank
                +"Main site"
            }
        }
    }
}

Забегая вперёд, скажу, что получившийся язык будет выглядеть примерно так:


Показать код:
val r = regex {
    group("prefix") {
        literally("+")
        oneOrMore { digit(); letter() }
    }
    3 times { digit() }
    literally(";")
    matchGroup("prefix")
}

Приступим




class RegexContext { }

fun regex(block: RegexContext.() -> Unit): Regex { throw NotImplementedError() }

Что тут написано? Функция regex возвращает построенный объект Regex и принимает единственный аргумент — другую функцию типа RegexContext.() -> Unit. Если вы уже хорошо знакомы с Kotlin, смело пропустите пару абзацев, объясняющих, что это.


Типы функций в Kotlin записываются как-то так: (Int, String) -> Boolean — это предикат двух аргументов — или так: SomeType.(Int) -> Unit — это функция, возвращающая Unit (аналог void-функции), и кроме аргумента Int принимающая ещё и receiver типа SomeType.


Функции, принимающие receiver, нам здорово помогают строить DSL благодаря тому, что можно передать в качестве аргумента такого типа лямбда-выражение, и у него появится неявный this, имеющий такой же тип, как receiver. Простой пример — библиотечная функция with:


fun <T, R> with(t: T, block: T.() -> R): R = t.block() 
// вызывает block со своим первым аргументом в качестве receiver

// передавая лямбду последним аргументом, можно писать её за скобками
with(ArrayList<Int>()) {
    for(i in 1..10) { add(i) } // вызов add с неявным использованием receiver
    println(this) // явное обращение к this -- это ArrayList<Int>
}

Отлично, теперь мы можем вызывать regex { ... } и внутри фигурных скобок работать с неким экземпляром RegexContext, как будто это this. Осталась самая малость — реализовать члены RegexContext. :)


Зачем нужен RegexContext?


Давайте составлять регулярное выражение по частям — каждый statement нашего DSL просто будет дописывать в недостроенное выражение очередную часть. Эти части и будет хранить RegexContext.


class RegexContext {
    internal val regexParts = mutableListOf<String>() // уже добавленные части

    private fun addPart(part: String) { // эту функцию будем вызывать в других
        regexParts.append(part) 
    }
}

Соответственно, функция regex {...} теперь будет выглядеть следующим образом:


fun regex(block: RegexContext.() -> Unit): Regex {
    val context = RegexContext()
    context.block() // вызываем block, который что-то сделает с context
    val pattern = context.regexParts.toString()
    return Regex(pattern) // компилируем собранный из частей паттерн-строку в Regex
}

Далее реализуем функции RegexContext, добавляющие разные части в регулярное выражение.


Следующие функции, если явно не сказано обратного, тоже расположены в теле класса.


Всё очень просто



Так ведь?


fun anyChar(s: String) = addPart(".")

Этот вызов просто добавляет в выражение точку, которой обозначается подвыражение, соответствующее любому одиночному символу.


Аналогично реализуем функции digit(), letter(), alphaNumeric(), whitespace(), wordBoundary(), wordCharacter() и даже startOfString() и endOfString() — все они выглядят примерно одинаково.


А именно:
fun digit() = addPart("\\d")

fun letter() = addPart("[[:alpha:]]")

fun alphaNumeric() = addPart("[A-Za-z0-9]")

fun whitespace() = addPart("\\s")

fun wordBoundary() = addPart("\\b")

fun wordCharacter() = addPart("\\w")

fun startOfString() = addPart("^")

fun endOfString() = addPart("$")

А вот для добавления произвольной строки в регулярное выражение придётся её сначала преобразовать, чтобы присутствующие в строке символы не интерпретировались как служебные. Самый простой способ это сделать — с помощью функции Regex.escape(...):


fun literally(s: String) = addPart(Regex.escape(s))

Например, literally(".:[test]:.") добавит в выражение часть \Q.:[test]:.\E.


Идём глубже


Что насчёт квантификаторов? Очевидное наблюдение: квантификатор навешивается на подвыражение, которое само по себе тоже валидный регекс. Давайте добавим немного вложенности!




Мы хотим вложенным блоком кода в фигурных скобках задавать подвыражение квантификатора, примерно так:


val r = regex {
    oneOrMore {
        optional { anyChar() }
        literally("-")
    }
    literally(";")
}

Делать мы это будем с помощью функций RegexContext, которые ведут себя почти так же, как regex {...}, но сами используют построенное подвыражение. Добавим сначала вспомогательные функции:


 private fun addWithModifier(s: String, modifier: String) {
     addPart("(?:$s)$modifier") // добавляет non-capturing group с модификатором
 }

 private fun pattern(block: RegexContext.() -> Unit): String { 
    // на самом деле немного иначе -- сюда мы ещё вернёмся
    val innerContext = RegexContext()
    innerContext.block() // block запускается на другом RegexContext
    return innerContext.regexParts.toString() // мы берём созданный им паттерн
} 

И потом используем их для реализации наших "квантификаторов":


fun optional(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "?")

fun oneOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "+")

И так далее (плюс, функции, позволяющие не заворачивать literally в лямбду
fun oneOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "+")
fun oneOrMore(s: String) = oneOrMore { literally(s) }

fun optional(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "?")
fun optional(s: String) = optional { literally(s) }

fun zeroOrMore(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "*")
fun zeroOrMore(s: String) = zeroOrMore { literally(s) }

Ещё в регексах есть возможность задавать количество ожидаемых вхождений точно или с помощью диапазона. Мы себе такое тоже хотим, правда? А ещё это хороший повод применить инфиксные функции — функции двух аргументов, один из которых — receiver. Вызовы таких функций будут выглядеть следующим образом:


val r = regex {
    3 times { anyChar() }
    2 timesOrMore { whitespace() }
    3..5 times { literally("x") } // 3..5 -- это IntRange
}

А сами функции объявим так:


infix fun Int.times(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "{$this}")

infix fun IntRange.times(block: RegexContext.() -> Unit) =  
    addWithModifier(pattern(block), "{${first},${last}}")

И все вместе, опять с функциями для строк-литералов:
infix fun Int.times(block: RegexContext.() -> Unit) = addWithModifier(pattern(block), "{$this}")
infix fun Int.times(s: String) = this times { literally(s) }

infix fun IntRange.times(block: RegexContext.() -> Unit) = 
    addWithModifier(pattern(block), "{${first},${last}}")
infix fun IntRange.times(s: String) = this times { literally(s) }

infix fun Int.timesOrMore(block: RegexContext.() -> Unit) = 
    addWithModifier(pattern(block), "{$this,}")
infix fun Int.timesOrMore(s: String) = this timesOrMore { literally(s) }

infix fun Int.timesOrLess(block: RegexContext.() -> Unit) = 
    addWithModifier(pattern(block), "{0,$this}")
infix fun Int.timesOrLess(s: String) = this timesOrLess { literally(s) }

Сгруппируйтесь!


Инструмент для работы с регексами не может таковым называться, если не поддерживает группы, поэтому давайте их поддерживать, например, в таком виде:


val r = regex {
    anyChar()
    val separator = group { literally("+"); digit() } // возвращает индекс группы
    anyChar()
    matchGroup(separator) // использует индекс группы
    anyChar()
}

Однако группы вносят новую сложность в структуру регекса: они нумеруются "насквозь" слева направо, игнорируя вложенность подвыражений. А значит, нельзя считать вызовы group {...} независимыми друг от друга, и даже больше: все наши вложенные подвыражения теперь тоже друг с другом связаны.


Чтобы поддерживать нумерацию групп, слегка изменим RegexContext: теперь он будет помнить, сколько групп в нём уже есть:


class RegexContext(var lastGroup: Int = 0) { ... }

И, чтобы наши вложенные контексты знали, сколько групп было до них и сообщали, сколько добавилось внутри них, изменим функцию pattern(...):


private fun pattern(block: RegexContext.() -> Unit): String {
    val innerContext = RegexContext(lastGroup) // передаём внутрь
    innerContext.block()
    lastGroup = innerContext.lastGroup // обновляем количество групп снаружи
    return innerContext.regexParts.toString()
}

Теперь нам ничего не мешает корректно реализовать group:


fun group(block: RegexContext.() -> Unit): Int {
    val result = ++lastGroup
    addPart("(${pattern(block)})")
    return result
}

Случай именованных групп:


fun group(name: String, block: RegexContext.() -> Unit): Int {
    val result = ++lastGroup
    addPart("(?<$name>${pattern(block)})")
    return result
}

И матчинг групп, как индексированных, так и именованных:


fun matchGroup(index: Int) = addPart("\\$index")
fun matchGroup(name: String) = addPart("\\k<$name>")

Что-то ещё?




Да! Мы чуть не забыли важную конструкцию регулярных выражений — альтернативы. Для литералов альтернативы реализуются тривиально:


fun anyOf(vararg terms: String) = addPart(terms.joinToString("|", "(?:", ")") { Regex.escape(it) }) 
// соберёт из terms одну строку с префиксом, суффиксом и разделителем, 
// сначала применив к каждой Regex.escape(...)

Не сложнее реализация для вложенных выражений:


 fun anyOf(vararg blocks: RegexContext.() -> Unit) = 
     addPart(blocks.joinToString("|", "(?:", ")") { pattern(it) })

То же и для наборов символов:
fun anyOf(vararg characters: Char) =
        addPart(characters.joinToString("", "[", "]").replace("\\", "\\\\").replace("^", "\\^"))

fun anyOf(vararg ranges: CharRange) = 
        addPart(ranges.joinToString("", "[", "]") { "${it.first}-${it.last}" })

Но постойте, а что если мы хотим в одном и том же anyOf(...) использовать в качестве альтернатив разные вещи — например, и строку, и блок с кодом для вложенного подвыражения? Тут нас ждёт небольшое разочарование: в Kotlin нет union types (типов-объединений), и написать тип аргумента String | RegexContext.() -> Unit | Char мы не можем. Обойти это я смог только устрашающего вида костылями, которые всё равно не делают DSL лучше, поэтому решил оставить всё так, как написано выше — в конце концов, и String, и Char можно написать во вложенных подвыражениях, используя соответствующую перегрузку anyOf {...}.


Страшные костыли
  • Использовать anyOf(vararg parts: Any), где Any — тип, которому принадлежит любой объект. Проверять, который из типов, соответственно, внутри, а в неосторожного пользователя, передавшего плохой аргумент, бросать IllegalArgumentException, чему он будет очень рад.


  • Хардкор. В Kotlin класс может переопределить оператор invoke(), и тогда объекты этого класса можно будет использовать как функции: myObject(arg), и если оператор имеет несколько перегрузок, то и объект будет вести себя как несколько перегрузок функции. Затем можно попробовать каррировать функцию anyOf(...), но, раз она имеет произвольное число аргументов, то мы не знаем, когда они закончатся — следовательно, каждое частичное применение должно отменять результат предыдущего и после этого применяться само, как будто его аргумент последний.


    Если это аккуратно сделать, оно даже заработает, но мы неожиданно упрёмся в неприятный момент в грамматике Kotlin: в цепочке вызовов оператора invoke нельзя использовать подряд вызовы с фигурными скобками.


    object anyOf {
        operator fun invoke(s: String) = anyOf // настоящее тело опущено для краткости
        operator fun invoke(r: RegexContext.() -> Unit) = anyOf
    }
    
    anyOf("a")("b")("c") // так можно
    anyOf("123") { anyChar() } { digit() } // а вот так нельзя!
    anyOf("123")({ anyChar() })({ digit() }) // можно так
    ((anyOf("123")) { anyChar() }) { digit() } // или так

    Ну, и нужно оно нам такое?



Кроме этого, неплохо было бы переиспользовать регулярные выражения, как построенные нашим DSL, так и пришедшие к нам откуда-то ещё. Сделать это несложно, главное — не забыть про нумерацию групп. Из регекса можно вытащить количество его групп: Pattern.compile(pattern).matcher("").groupCount(), и остаётся только реализовать соответствующую функцию RegexContext:


fun include(regex: Regex) {
    val pattern = regex.pattern
    addPart(pattern)
    lastGroup += Pattern.compile(pattern).matcher("").groupCount()
}

И на этом, пожалуй, обязательные фичи заканчиваются.


Заключение


Спасибо, что дочитали до конца! Что у нас получилось? Вполне жизнеспособный DSL для регексов, которым можно пользоваться:


fun RegexContext.byte() = anyOf({ literally("25"); anyOf('0'..'5') },
                                { literally("2"); anyOf('0'..'4'); digit() },
                                { anyOf("0", "1", ""); digit(); optional { digit() } })
val r = regex {
    3 times { byte(); literally(".") }
    byte()
}

(Вопрос: для чего этот регекс? Правда же, он простой?)


Ещё достоинства:


  • Сложно поломать регекс: даже скобки руками писать не надо, если код компилируется и группы правильные, то и регекс валидный.
  • Получается наглядно формировать регекс динамически: это делает живой код с любыми валидными конструкциями вроде условий, циклов и вызовов сторонних функций.
  • Если вы используете индексированные группы, то индекс группе назначается динамически, и даже изменение большого регекса, написанного на DSL, не поломает индексы групп.
  • Расширяемость и переиспользуемость: можно в своём коде написать любую функцию-расширение, подобную byte() выше, и использовать её как составную часть в регексах — russianLetter(), ipAddress(), time()...

Что не получилось:


  • Угловато выглядит anyOf(...), не удалось добиться лучшего.
  • Плотность записи сильно уступает традиционной форме, регекс в полэкрана длиной превращается в блок в полэкрана высотой. Зато, наверное, читаемо.

Исходники, тесты и готовая к добавлению в проект зависимость — в репозитории на Github.


Что вы думаете по поводу предметно-ориентированных языков для регулярных выражений? Пользовались ли хоть раз? А другими DSL?


Буду рад обсудить всё, что прозвучало.


Удачи!

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 9

    +1
    Это действительно хорошее применение DSL — ведь сырой regex читается с трудом. Но я в своей работе DSL так никогда и не использовал, — то ли задач не было то ли не смог найти адекватное применение. Было бы интересно так же почитать как DSL помогает в других рутинных, но реальных задачах.
      0
      Я постоянно использую собственный достаточно простой класс ComposedRegex для парсинга мэйлов, телефонов, почтовых шаблонов, шаблонов сообщений пользователю. На статью такой кейс не потянет, но задачи реальные, рутинные и повседневные…
      +3
      Отвечая на вопрос в конце:
      Если мы отказываемся от компактного синтаксиса регулярных выражений, то лучше уж сразу перейти к комбинаторным парсерам.
      Примеры на scala: http://www.artima.com/pins1ed/combinator-parsing.html. Мой опыт с ними: https://habrahabr.ru/post/176285/
      Пример с byte на парсерах:
      object byteParser extends JavaTokenParsers {
        def digit = elem("digit", '0' to '9' toSet)
        def top = "25" ~ elem("0 to 5", '0' to '5' toSet)
        def middle = '2' ~ elem("0 to 4", '0' to '4' toSet) ~ digit
        def low = ("0" | "1") ~ digit ~ digit | digit ~ digit.?
        def byte = top | middle | low
      
        def apply(s: String) = parseAll(byte, s)
      }
      
      
      println(byteParser("256"))
      //failure: 0 to 5 expected
      //
      //256
      //  ^
      


        0

        Дам всего один простой совет: если не можете прочитать сложное регулярное выражение- в коде в комментарии к нему указывайте ссылку на regex101, там редактируйте его с подсветкой синтаксиса, подсказками, тестами. И не забывайте про режим eXtended- в нем регулярные выражения любой сложности читаются очень легко.
        Сложное регулярное выражение на DSL будет гораздо большим трэшем, чем его обычная запись, как мне кажется.

          0
          У любого кода, синтаксиса, DSL есть особенность — пока ты с ним плотно работаешь — он читается и понятен, как только ты его не видел какое-то время он забывается. Для меня возвращение к сложным регекспам на разных языках (с учетом того что тут 14 стандартов) всегда боль, и тут конечно же regex101 like удобно. Но зачем сервис, если есть такой приятный DSL, с которым я легко прочитал все примеры? При этом с Kotlin, в отличии от других языков, не нужно поддерживать хинты для IDE или еще что-то, IDE подсказывает мне что я могу использовать в конкретном месте без лишних телодвижений со стороны автора или пользователя.
          0
          да и банальной проверки соответствия открывающих и закрывающих элементов нет, можно написать лишний .end();

          Так можно же проверку сделать! Так что и лишний не напишешь, и не лишний не забудешь.

            0
            Согласен, во время написания поста тоже думал, что для цепочек вызовов это должно быть осуществимо примерно так, как написано у вас (спасибо что показали, как именно!). В таком случае остаётся только такой недостаток, как необходимость воевать с форматтером, чтобы он не выровнял все вызовы функций.
              0

              Это да, потому и не стал цитировать пункт целиком. Более того, я боюсь что в этой войне его вообще не победить. Максимум, что можно было бы сделать (да и то хз как) — запретить форматировать конкретный кусок. Но, на мой взгляд, вторая проблема (динамический запрос) даже хуже.


              А у вас хорошо получилось, статическая типизация — наше всё.

                0
                Форматтеру IDEA вполне можно запретить форматировать участок кода, но по умолчанию эта фича выключена. Например, здесь это позволило избежать лишнего большого отступа, который бы добавился из-за того, что это аргумент конструктора. :)

          Only users with full accounts can post comments. Log in, please.