みなさん、こんにちは!
株式会社コードリックの足立です。
前回はVSCodeの拡張機能を作成するために、使用するAPIの確認を行いました。
今回の目標はVueファイルからSass形式で宣言されたスタイルシートのパースを行うことです。
それでは早速進めていきましょう!
前回の記事はこちらから読めます。
よろしければどうぞ!
VSCodeでVue + SassのPeekができる拡張機能を作ってみた
・パーサとパーサコンビネータ
パーサの作り方はいくつか種類がありますが、今回はTypeScriptでパーサコンビネータを利用して書いてみます。
パーサコンビネータでは、単純で小さいパーサとそれらのパーサをつなぎ合わせるコンビネータを組み合わせて複雑で大きなパーサを構築します。
パーサジェネレータでも同じようにパーサの構築ができるのですが、パーサコンビネータはDSLを使う必要がなく、TypeScriptで完結するので使い勝手がいいです。
parjsや
ts-parsecといったライブラリがありますが、今回はparjsを利用します。
どちらも
Parsec由来なので使用感はそこまで変わらないと思っていますがどうなのでしょうか。
・parjsの使い方
parjsの使い方を下記のパーサを参考に見ていきましょう。
import * as pos from 'parjs/internal/parsers/position'
import {
many
} from 'parjs/combinators'
import { space } from 'parjs/internal/parsers/char-types'
import { Parjser } from 'parjs/index'
// 空白を消費するパーサ
const spaces = space().pipe(many())
// Sassのidentifierにマッチする
const selectorIdentifier: Parjser<[number, string]> =
pos.position() // identifierの開始位置記憶
.pipe(then(regexp(/\S*/).pipe(map(val => val[0]))))
// Sassのブロックの中の1行にマッチする
const sassClassContentLine: Parjser<string> = spaces
.pipe(qthen(regexp(/.*/)))
.pipe(thenq(nl.newline()))
.pipe(map((val) => val[0]))
パーサとコンビネータ
まずは、spacesというパーサについて見てみましょう。
space()という1文字の空白文字にマッチするパーサがあり、このパーサをmanyコンビネータに適用しています。
あるパーサから新たなパーサをコンビネータを利用して生成した形です。
manyコンビネータは受け取ったパーサを連続して実行します。マッチした結果は配列で返されます。
各パーサの型はParjser<T>の形で表現され、Tにはそのパーサの結果の型が入ります。たとえばspacesの場合はParjser[string[]]になります。
また、パースの結果にロケーションの情報も持たせることができます。
selectorIdentifierはSassのクラス名などにマッチする規則です。pos.positionでマッチした位置を記憶し、thenコンビネータで後続のパーサを呼び出します。
map処理で持ち運んでいるデータを加工することができます。
qthenやthenqはParsecでいうところの「*>」や「<*」に相当し、先行したパーサや後続したパーサの結果を捨てるのに使います。
このような感じで小さいパーサを再利用しながら、目的のパーサを構築しています。
parjsにおける「または」の扱い
parjsでp
1 | p
2 のようなパーサを書きたいときは注意が必要です。
orコンビネータを使えば実現可能ですが、そのまま書くとp
1にマッチしないときに前者でHard failuerのエラーが出て、後続のパーサの実行ができないからです。
そこでparjsでは必要に応じてp
1のHard failuerをSoft failuerに変換する必要があります。
// 変換用データ
const softFailureValue: [number, string][] = [[0, '']]
// Sassで宣言されたクラスっぽいものにマッチする
const sassClassName: Parjser<[number, string][]> = spaces
.pipe(qthen(string('.')))
.pipe(qthen(selectorIdentifier))
.pipe(thenq(spaces))
.pipe(manyTill(nl.newline()))
.pipe(recover((_) => ({ kind: 'Soft', value: softFailureValue }))) // 失敗したときにsoftに変換
// sassClassName | sassIdName のパーサが実行できるようになる
const sassSelectorName: Parjser<[number, string][]> =
sassClassName.pipe(or(sassIdName))
全体像
以上を踏まえてVueファイル全体からSassのブロックを切り出していくパーサを実装します。
真面目に実装するととても重そうなので、お手軽に自分がよく使うであろう書き方のみをサポートします。
// Sassのブロックを表現するクラス
export class SassClass {
className: string
classContent: string
start: number
end: number
constructor (
className: string,
classContent: string[],
start: number,
end: number
) {
this.className = className
this.classContent = classContent.reduce((acc, content) => acc + content, '')
this.start = start
this.end = end
return this
}
}
import * as vscode from 'vscode'
import { regexp, rest, string } from 'parjs'
import {
many,
map,
or,
qthen,
thenq,
manyTill,
then,
backtrack,
recover
} from 'parjs/combinators'
import { Parjser } from 'parjs/index'
import * as pos from 'parjs/internal/parsers/position'
import * as nl from 'parjs/internal/parsers/newline'
import { space } from 'parjs/internal/parsers/char-types'
import { SassClass } from './SassClass'
export class SassClassDefinitionProvider implements vscode.DefinitionProvider {
constructor() {}
/**
* generate array of Sass class definitions from text
* @param {string} text - input source
* @returns {SassClass} array of Sass class definition
*/
genSassClassDefinitionsFromText(text: string): SassClass[] {
// match an identifier of Sass class
const selectorIdentifier: Parjser<[number, string]> = pos
.position() // get the start position of the class definition
.pipe(then(regexp(/\S*/).pipe(map((val) => val[0]))))
// match some spaces
const spaces = space().pipe(many())
// use for converting hard failures to soft failures
// emulate "try" parser with using softFailure and backtrack() combinator
const softFailureValue: [number, string][] = [[0, '']]
const sassIdName: Parjser<[number, string][]> = spaces
.pipe(qthen(string('#')))
.pipe(qthen(selectorIdentifier))
.pipe(thenq(spaces))
.pipe(manyTill(nl.newline()))
.pipe(recover((_) => ({ kind: 'Soft', value: softFailureValue })))
// match a definition of Sass class name
// support
// o - single defintion of the class
// o - multiple definitions of the classes
// x - nested class definitons
const sassClassName: Parjser<[number, string][]> = spaces
.pipe(qthen(string('.')))
.pipe(qthen(selectorIdentifier))
.pipe(thenq(spaces))
.pipe(manyTill(nl.newline()))
.pipe(recover((_) => ({ kind: 'Soft', value: softFailureValue })))
const sassSelectorName: Parjser<[number, string][]> = sassClassName.pipe(
or(sassIdName)
)
// match a style tag
const styleOpenTag = regexp(/\s*<style.*>\s*/)
// match a style close tag
const styleCloseTag = spaces
.pipe(then(string('</style>')))
.pipe(then(spaces))
.pipe(then(nl.newline()))
.pipe(recover((_) => ({ kind: 'Soft', value: softFailureValue })))
// match a line of Sass class content
const sassClassContentLine: Parjser<string> = spaces
.pipe(qthen(regexp(/.*/)))
.pipe(thenq(nl.newline()))
.pipe(map((val) => val[0]))
// match a sass class definition
const singleSassClass: Parjser<[[[number, string][], string[]], number]> =
sassClassContentLine
.pipe(manyTill(sassSelectorName.pipe(backtrack())))
.pipe(qthen(sassSelectorName))
.pipe(
then(
sassClassContentLine.pipe(
manyTill(
styleCloseTag.pipe(or(sassSelectorName)).pipe(backtrack())
)
)
)
)
.pipe(then(pos.position())) // get the end position of the class definition
// match all sass class definitions
const sassClasses: Parjser<[[[number, string][], string[]], number][]> =
singleSassClass.pipe(manyTill(styleCloseTag.pipe(backtrack())))
// convert to SassClass Object
const sassParser: Parjser<SassClass[]> = sassClasses.pipe(
map((val) =>
val.reduce(
(acc, sassDefinition) =>
acc.concat(
sassDefinition[0][0].map(
(sassClassName) =>
new SassClass(
sassClassName[1],
sassDefinition[0][1],
sassClassName[0],
sassDefinition[1]
)
)
),
new Array<SassClass>()
)
)
)
// arbitrary texts before style open tag
const otherPrevText = regexp(/(.*)/)
.pipe(then(nl.newline()))
.pipe(manyTill(styleOpenTag))
// match a vue file text
// does not support multiple style tags
const vueSassParser = otherPrevText
.pipe(qthen(sassParser))
.pipe(thenq(styleCloseTag))
.pipe(thenq(rest()))
const sassClassesParjser = vueSassParser.parse(text)
console.log(sassClassesParjser)
return sassClassesParjser.isOk ? sassClassesParjser.value : []
}
/**
* override provideDefinition
* @param {TextDocument} document - target document
* @param {Position} position - position
* @param {CancellationToken} token - token
* @returns {Location[]} array of locations of the definitions
*/
provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
) {
const targetText = document.getText()
const editor = vscode.window.activeTextEditor
const selection = document.getWordRangeAtPosition(
editor?.selection.active ?? new vscode.Position(0, 0)
)
const selectedText = document.getText(selection)
return this.genSassClassDefinitionsFromText(targetText)
.filter((sassClass) => sassClass.className === selectedText)
.map(
(targetSassClass) =>
new vscode.Location(
document.uri,
new vscode.Range(
document.positionAt(targetSassClass.start),
document.positionAt(targetSassClass.end)
)
)
)
}
}
こちらからでも確認できます。
https://github.com/adachi-codelic/vue-sass-peek
拡張機能の実際の動作
(このページから読んでいる方はよくわからないと思いますが、もともとVSCodeの拡張機能作成のためにパーサを書いていました)
まとめ
今回はparjsというTypeScriptのパーサコンビネータのライブラリを使ってパーサを作ってみました。
前回のブログと合わせて閲覧していただけましたでしょうか。
他のブログも面白いのでぜひ覗いてみてください。