Créer une extension VS Code pour booster son DSL
Loïc Knuchel
12 déc. 2024
@loicknuchel
Loïc Knuchel
Créateur de
@loicknuchel - https://github.com/loicknuchel
@azimuttapp - https://github.com/azimuttapp
Database design ?
Créer une extension VS Code
npx --package yo --package generator-code -- yo code
Coloration syntaxique
Coloration syntaxique
"contributes": {
"languages": [{
"id": "amll",
"extensions": [".amll"],
"icon": {
"dark": "./assets/icon.svg",
"light": "./assets/icon.svg"
}
}],
"grammars": [{
"language": "amll",
"scopeName": "source.amll",
"path": "./amll.tmLanguage.json"
}]
}
{
"scopeName": "source.amll",
"patterns": [
{"include": "#entity"},
{"include": "#keyword"}
],
"repository": {
"entity": {"patterns": [{
"name": "entity.amll",
"match": "^[a-zA-Z0-9_]+\\b"
}]},
"keyword": {"patterns": [{
"name": "keyword.amll",
"match": "\\bpk\\b"
}]}
}
}
package.json
amll.tmLanguage.json
Parser
AML parser
users
id int pk
name varchar
posts
id int pk
title varchar
author int -> users(id)
[{
kind: "Entity",
name: {value: "users", token: {
offset: {start: 0, end: 4},
position: {start: {line: 1, col: 1}, end: {line: 1, col: 5}}
}},
attrs: [
{name: {value: "id", ...}, type: {value: "int", ...}, pk: {...}},
{name: {value: "name", ...}, type: {value: "varchar", ...}}
]
}, {
kind: "Empty"
}, ...]
function parseAml(content: string): {value?: AmlStatement[], errors: ParserError[]}
Erreurs contextuelles
Erreurs contextuelles
function activate(context: ExtensionContext) {
const diagnostics = vscode.languages.createDiagnosticCollection('amll')
}
Erreurs contextuelles
function activate(context: ExtensionContext) {
const diagnostics = vscode.languages.createDiagnosticCollection('amll')
vscode.workspace.onDidOpenTextDocument((document: TextDocument) => {
}, null, context.subscriptions)
}
Erreurs contextuelles
function activate(context: ExtensionContext) {
const diagnostics = vscode.languages.createDiagnosticCollection('amll')
vscode.workspace.onDidOpenTextDocument((document: TextDocument) => {
if (document.languageId === 'amll') {
diagnostics.set(document.uri, computeDiagnostics(document.getText()))
}
}, null, context.subscriptions)
}
Erreurs contextuelles
function activate(context: ExtensionContext) {
const diagnostics = vscode.languages.createDiagnosticCollection('amll')
vscode.workspace.onDidOpenTextDocument((document: TextDocument) => {
if (document.languageId === 'amll') {
diagnostics.set(document.uri, computeDiagnostics(document.getText()))
}
}, null, context.subscriptions)
vscode.workspace.onDidChangeTextDocument(debounce((event: TextDocumentChangeEvent) => {
if (event.document.languageId === 'amll') {
diagnostics.set(event.document.uri, computeDiagnostics(event.document.getText()))
}
}, 300), null, context.subscriptions)
context.subscriptions.push(new Disposable(() => diagnostics.dispose()))
}
Erreurs contextuelles
function computeDiagnostics(content: string): Diagnostic[] {
return parseAml(content).errors
}
Erreurs contextuelles
function computeDiagnostics(content: string): Diagnostic[] {
return parseAml(content).errors.map(e => {
const {start, end} = e.pos.position
const range = new Range(start.line - 1, start.column - 1, end.line - 1, end.column)
return new Diagnostic(range, e.message, e.level)
})
}
Erreurs contextuelles
function computeDiagnostics(content: string): Diagnostic[] {
const res = parseAml(content)
const entities = res.value?.filter(s => s.kind === 'Entity') || []
const entitiesByName = groupBy(entities, e => e.name.value)
return res.errors.concat(
duplicateEntities(entitiesByName),
duplicateAttributes(entities),
badReferences(entities, entitiesByName)
).map(e => {
const {start, end} = e.pos.position
const range = new Range(start.line - 1, start.column - 1, end.line - 1, end.column)
return new Diagnostic(range, e.message, e.level)
})
}
Erreurs contextuelles
function badRefs(entities: AmlEntity[], entitiesByName: Record<string, AmlEntity[]>) {
return entities.flatMap(e => e.attrs.flatMap(a => a.ref ? [a.ref] : []))
}
const warn = (message: string, pos: ParserInfo) => ({level: 'warning',message, pos})
Erreurs contextuelles
function badRefs(entities: AmlEntity[], entitiesByName: Record<string, AmlEntity[]>) {
return entities.flatMap(e => e.attrs.flatMap(a => a.ref ? [a.ref] : [])).flatMap(r => {
const entity = entitiesByName[r.entity.value]?.[0]
if (entity) {
}
return [warn(`${r.entity.value} does not exist`, r.entity.token)]
})
}
const warn = (message: string, pos: ParserInfo) => ({level: 'warning',message, pos})
Erreurs contextuelles
function badRefs(entities: AmlEntity[], entitiesByName: Record<string, AmlEntity[]>) {
return entities.flatMap(e => e.attrs.flatMap(a => a.ref ? [a.ref] : [])).flatMap(r => {
const entity = entitiesByName[r.entity.value]?.[0]
if (entity) {
const a = entity.attrs.find(a => a.name.value === r.attr.value)
return a ? [] : [warn(`${r.attr.value} not in ${r.entity.value}`, r.attr.token)]
}
return [warn(`${r.entity.value} does not exist`, r.entity.token)]
})
}
const warn = (message: string, pos: ParserInfo) => ({level: 'warning',message, pos})
Code suggestions
Code suggestions
"contributes": {
"snippets": [{
"language": "amll",
"path": "./snippets.json"
}]
}
{
"Primary key": {
"prefix": "id",
"body": ["id ${1|int,uuid|} pk"],
"description": "A Primary Key column"
},
"Reference": {
"prefix": ["->"],
"body": ["-> ${1:entity}(${2:attr})"]
},
"Created At": {
"prefix": "created_at",
"body": ["created_at ${1:timestamp}"]
}
}
package.json
snippets.json
Code suggestions
class AmlCompletion implements CompletionItemProvider {
provideCompletionItems(doc: TextDocument, pos: Position): CompletionItem[] {
...
}
}
Code suggestions
class AmlCompletion implements CompletionItemProvider {
provideCompletionItems(doc: TextDocument, pos: Position): CompletionItem[] {
...
}
}
function activate(context: ExtensionContext) {
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{language: 'amll'},
new AmlCompletion(),
' ', '('
)
)
}
Code suggestions
class AmlCompletion implements CompletionItemProvider {
provideCompletionItems(doc: TextDocument, pos: Position): CompletionItem[] {
const res = parseAml(doc.getText())
if (res.value) {
const line = document.lineAt(pos.line).text.slice(0, pos.character)
return computeSuggestions(line, res.value)
}
}
}
Code suggestions
const attrTypes = ['int', 'bigint', 'varchar', 'text', 'uuid', 'timestamp', 'json']
function computeSuggestions(line: string, ast: AmlStatement[]): CompletionItem[] {
const entities = ast.filter(s => s.kind === 'Entity')
const pks = entities.flatMap(e => e.attrs.flatMap(a => a.pk ? [{
entity: e.name.value,
attr: a.name.value
}] : []))
if (line.match(/^ [a-zA-Z0-9_]+ /)) // suggest attribute types
return attrTypes.map(suggestType)
if (line.match(/^ [a-zA-Z0-9_]+ [a-zA-Z0-9_]+ /)) // suggest relations
return pks.map(pk => suggestRelation(`-> ${pk.entity}(${pk.attr})`))
}
Code suggestions
function suggestType(type: string): CompletionItem {
const item = new CompletionItem(type)
item.kind = CompletionItemKind.TypeParameter
return item
}
function suggestRelation(relation: string): CompletionItem {
const item = new CompletionItem(relation)
item.kind = CompletionItemKind.Interface
return item
}
Renommage sémantique
Renommage sémantique
class AmlRename implements RenameProvider {
prepareRename?(doc: TextDocument, pos: Position): Range {
...
}
provideRenameEdits(doc: TextDocument, pos: Position, newName: string): WorkspaceEdit {
...
}
}
Renommage sémantique
class AmlRename implements RenameProvider {
prepareRename?(doc: TextDocument, pos: Position): Range {
...
}
provideRenameEdits(doc: TextDocument, pos: Position, newName: string): WorkspaceEdit {
...
}
}
function activate(context: ExtensionContext) {
context.subscriptions.push(
vscode.languages.registerRenameProvider({language: 'amll'}, new AmlRename())
)
}
Renommage sémantique
class AmlRename implements RenameProvider {
prepareRename?(doc: TextDocument, pos: Position): Range {
const item = findItem(parseAml(doc.getText()).value, positionToAml(pos))
return tokenToRange(item.pos.position)
}
provideRenameEdits(doc: TextDocument, pos: Position, newName: string): WorkspaceEdit {
...
}
}
Renommage sémantique
class AmlRename implements RenameProvider {
prepareRename?(doc: TextDocument, pos: Position): Range {
const item = findItem(parseAml(doc.getText()).value, positionToAml(pos))
return tokenToRange(item.pos.position)
}
provideRenameEdits(doc: TextDocument, pos: Position, newName: string): WorkspaceEdit {
const ast = parseAml(doc.getText()).value
const item = findItem(ast, positionToAml(pos))
const edits = new WorkspaceEdit()
itemPositions(ast, item).forEach(p => edits.replace(doc.uri, toRange(p), newName))
return edits
}
}
Renommage sémantique
function findItem(ast: AmlStatement[], pos: EditorPosition): AmlItem | undefined {
const s = ast.find(s => isInside(pos, s.pos.position))
if (s?.kind === 'Entity') {
if (inside(pos, s.name)) { return entityItem(s.name) }
const a = s.attrs.find(a => isInside(pos, a.pos.position))
if (a) {
if (inside(pos, a.name)) { return attributeItem(s.name, a.name) }
if (inside(pos, a.type)) { return typeItem(a.type) }
if (a.ref) {
if (inside(pos, a.ref.entity)) { return entityItem(a.ref.entity) }
if (inside(pos, a.ref.attr)) { return attributeItem(a.ref.entity, a.ref.attr) }
}
}
}
}
Renommage sémantique
function itemPositions(ast: AmlStatement[], item: AmlItem): ParserInfo[] {
const identifiers: AmlIdentifier[] = []
ast.filter(s => s.kind === 'Entity').forEach(s => {
if (isEntity(item, s.name)) { identifiers.push(s.name) }
s.attrs.forEach(a => {
if (isAttribute(item, s.name, a.name)) { identifiers.push(a.name) }
if (isType(item, a.type)) { identifiers.push(a.type) }
if (a.ref) {
if (isEntity(item, a.ref.entity)) { identifiers.push(a.ref.entity) }
if (isAttribute(item, a.ref.entity, a.ref.attr)) { identifiers.push(a.ref.attr) }
}
})
})
return identifiers.map(t => t.token)
}
Symbol navigation
Symbol navigation
class AmlSymbols implements DocumentSymbolProvider {
provideDocumentSymbols(doc: TextDocument): DocumentSymbol[] {
return parseAml(doc.getText()).value.filter(s => s.kind === 'Entity').map(s => {
const entity = symbol(s, SymbolKind.Class)
entity.children = s.attrs.map(a => symbol(a, SymbolKind.Property))
return entity
})
}
}
function activate(context: ExtensionContext) {
context.subscriptions.push(
vscode.languages.registerDocumentSymbolProvider({language: 'amll'}, new AmlSymbols())
)
}
Au prochain épisode… 😅
https://bit.ly/react-paris-vscode
https://github.com/loicknuchel/vscode-aml-lite
https://marketplace.visualstudio.com/items?itemName=azimutt.vscode-aml
Mais surtout: Language Server 🤯