1 of 60

Créer une extension VS Code pour booster son DSL

Loïc Knuchel

12 déc. 2024

@loicknuchel

2 of 60

Loïc Knuchel

Créateur de

3 of 60

4 of 60

Database design ?

5 of 60

6 of 60

7 of 60

8 of 60

Créer une extension VS Code

9 of 60

npx --package yo --package generator-code -- yo code

10 of 60

11 of 60

12 of 60

13 of 60

Coloration syntaxique

14 of 60

15 of 60

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

16 of 60

17 of 60

Parser

18 of 60

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[]}

19 of 60

Erreurs contextuelles

20 of 60

21 of 60

Erreurs contextuelles

function activate(context: ExtensionContext) {

const diagnostics = vscode.languages.createDiagnosticCollection('amll')

}

22 of 60

Erreurs contextuelles

function activate(context: ExtensionContext) {

const diagnostics = vscode.languages.createDiagnosticCollection('amll')

vscode.workspace.onDidOpenTextDocument((document: TextDocument) => {

}, null, context.subscriptions)

}

23 of 60

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)

}

24 of 60

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()))

}

25 of 60

Erreurs contextuelles

function computeDiagnostics(content: string): Diagnostic[] {

return parseAml(content).errors

}

26 of 60

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)

})

}

27 of 60

28 of 60

29 of 60

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)

})

}

30 of 60

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})

31 of 60

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})

32 of 60

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})

33 of 60

34 of 60

Code suggestions

35 of 60

36 of 60

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

37 of 60

38 of 60

39 of 60

Code suggestions

class AmlCompletion implements CompletionItemProvider {

provideCompletionItems(doc: TextDocument, pos: Position): CompletionItem[] {

...

}

}

40 of 60

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(),

' ', '('

)

)

}

41 of 60

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)

}

}

}

42 of 60

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})`))

}

43 of 60

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

}

44 of 60

45 of 60

46 of 60

Renommage sémantique

47 of 60

48 of 60

Renommage sémantique

class AmlRename implements RenameProvider {

prepareRename?(doc: TextDocument, pos: Position): Range {

...

}

provideRenameEdits(doc: TextDocument, pos: Position, newName: string): WorkspaceEdit {

...

}

}

49 of 60

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())

)

}

50 of 60

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 {

...

}

}

51 of 60

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

}

}

52 of 60

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) }

}

}

}

}

53 of 60

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)

}

54 of 60

55 of 60

56 of 60

Symbol navigation

57 of 60

58 of 60

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())

)

}

59 of 60

Au prochain épisode… 😅

  • go-to-definition
  • hover overlay
  • quick fixes
  • action hints

Mais surtout: Language Server 🤯

60 of 60