From b1a642d24b69c3052fe198484e8247e425e0da71 Mon Sep 17 00:00:00 2001 From: Reda Al Sulais Date: Sat, 4 Nov 2023 20:46:45 +0300 Subject: [PATCH] feat: add more services to statemachine example - add `Formatting` service - add `CodeActionProvider` service - Remove an unreachable commands, events, and states - Rename all refereed state names that don't start with caps - validate unreached commands, events, and states --- .../example/trafficlight.statemachine | 2 +- .../statemachine-code-actions.ts | 151 ++++++++++++++++++ .../language-server/statemachine-formatter.ts | 52 ++++++ .../language-server/statemachine-module.ts | 10 ++ .../language-server/statemachine-validator.ts | 127 ++++++++++++++- 5 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 examples/statemachine/src/language-server/statemachine-code-actions.ts create mode 100644 examples/statemachine/src/language-server/statemachine-formatter.ts diff --git a/examples/statemachine/example/trafficlight.statemachine b/examples/statemachine/example/trafficlight.statemachine index 082a080f2..c0e081825 100644 --- a/examples/statemachine/example/trafficlight.statemachine +++ b/examples/statemachine/example/trafficlight.statemachine @@ -1,6 +1,6 @@ statemachine TrafficLight -events +events switchCapacity next diff --git a/examples/statemachine/src/language-server/statemachine-code-actions.ts b/examples/statemachine/src/language-server/statemachine-code-actions.ts new file mode 100644 index 000000000..7309afba7 --- /dev/null +++ b/examples/statemachine/src/language-server/statemachine-code-actions.ts @@ -0,0 +1,151 @@ +/****************************************************************************** + * Copyright 2021 TypeFox GmbH + * This program and the accompanying materials are made available under the + * terms of the MIT License, which is available in the project root. + ******************************************************************************/ + +import type { + AstNode, + DiagnosticData, + LangiumDocument, + MaybePromise, + Reference, +} from 'langium'; +import type { CodeActionProvider } from 'langium/lsp'; +import type { + CodeActionParams, + Command, + CodeAction, + Diagnostic, + TextEdit, + Range, + Position, +} from 'vscode-languageserver'; +import { CodeActionKind } from 'vscode-languageserver'; +import { IssueCodes } from './statemachine-validator.js'; +import type { State, Statemachine } from './generated/ast.js'; + +export class StatemachineCodeActionProvider implements CodeActionProvider { + getCodeActions( + document: LangiumDocument, + params: CodeActionParams + ): MaybePromise | undefined> { + const result: CodeAction[] = []; + const acceptor = (ca: CodeAction | undefined) => ca && result.push(ca); + for (const diagnostic of params.context.diagnostics) { + this.createCodeActions(diagnostic, document, acceptor); + } + return result; + } + + private createCodeActions( + diagnostic: Diagnostic, + document: LangiumDocument, + accept: (ca: CodeAction | undefined) => void + ): void { + switch ((diagnostic.data as DiagnosticData)?.code) { + case IssueCodes.StateNameUppercase: + accept(this.makeUpperCase(diagnostic, document)); + break; + case IssueCodes.UnreachedState: + case IssueCodes.UnreachedCommand: + case IssueCodes.UnreachedEvent: + accept(this.removeUnusedSymbol(diagnostic, document)); + break; + } + return undefined; + } + + private makeUpperCase( + diagnostic: Diagnostic, + document: LangiumDocument + ): CodeAction { + const changes: TextEdit[] = []; + + const stateName = document.textDocument.getText(diagnostic.range); + const { init, states } = document.parseResult.value as Statemachine; + this.updateChangesForReferencedState(init, stateName, document, changes); + + states.forEach(({ transitions }) => { + transitions.forEach(({ state }) => { + this.updateChangesForReferencedState( + state, + stateName, + document, + changes + ); + }); + }); + + const range = this.getFirstLetterRange(diagnostic.range.start); + changes.push(this.createTextEditForState(range, document)); + return { + title: 'First letter to upper case', + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: true, + edit: { + changes: { + [document.textDocument.uri]: changes, + }, + }, + }; + } + + private createTextEditForState( + range: Range, + document: LangiumDocument + ): TextEdit { + const changeRange = this.getFirstLetterRange(range.start); + return { + range: changeRange, + newText: document.textDocument.getText(changeRange).toUpperCase(), + }; + } + + private updateChangesForReferencedState( + state: Reference, + name: string, + document: LangiumDocument, + changes: TextEdit[] + ): void { + if (state.$refNode && state.ref && state.ref.name === name) { + const { range } = state.$refNode; + const changeRange = this.getFirstLetterRange(range.start); + changes.push(this.createTextEditForState(changeRange, document)); + } + } + + private getFirstLetterRange(position: Position): Range { + const range: Range = { + start: position, + end: { + line: position.line, + character: position.character + 1, + }, + }; + return range; + } + + private removeUnusedSymbol( + diagnostic: Diagnostic, + document: LangiumDocument + ): CodeAction { + return { + title: 'Remove unsed symbol', + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: true, + edit: { + changes: { + [document.textDocument.uri]: [ + { + range: diagnostic.range, + newText: '', + }, + ], + }, + }, + }; + } +} diff --git a/examples/statemachine/src/language-server/statemachine-formatter.ts b/examples/statemachine/src/language-server/statemachine-formatter.ts new file mode 100644 index 000000000..66755c836 --- /dev/null +++ b/examples/statemachine/src/language-server/statemachine-formatter.ts @@ -0,0 +1,52 @@ +/****************************************************************************** + * Copyright 2021 TypeFox GmbH + * This program and the accompanying materials are made available under the + * terms of the MIT License, which is available in the project root. + ******************************************************************************/ + +import type { AstNode } from 'langium'; +import { AbstractFormatter, Formatting } from 'langium/lsp'; +import * as ast from './generated/ast.js'; + +export class StatemachineFormatter extends AbstractFormatter { + protected format(node: AstNode): void { + if (ast.isState(node)) { + const formatter = this.getNodeFormatter(node); + formatter.keyword('state') + .prepend(Formatting.newLine({ allowMore: true })) + .append(Formatting.oneSpace()); + + formatter.keyword('actions').append(Formatting.oneSpace()); + const bracesOpen = formatter.keyword('{'); + bracesOpen.prepend(Formatting.fit(Formatting.oneSpace(), Formatting.newLine())); + const bracesClose = formatter.keyword('}'); + bracesClose.prepend(Formatting.newLine()); + formatter.interior(bracesOpen, bracesClose).prepend(Formatting.indent()); + + const stateName = formatter.property('name'); + const stateEnd = formatter.keyword('end'); + formatter.interior(stateName, stateEnd).prepend(Formatting.indent()); + stateEnd.prepend(Formatting.newLine()); + } else if (ast.isStatemachine(node)) { + const formatter = this.getNodeFormatter(node); + + formatter.keyword('statemachine').append(Formatting.oneSpace()); + formatter.properties('name').append(Formatting.newLine({ allowMore: true })); + + formatter.keyword('initialState') + .prepend(Formatting.newLine({ allowMore: true })) + .append(Formatting.oneSpace()); + formatter.property('init').append(Formatting.newLine({ allowMore: true })); + + formatter.keyword('commands') + .prepend(Formatting.newLine({ allowMore: true })); + formatter.keyword('events') + .prepend(Formatting.newLine({ allowMore: true })); + const nodes = formatter.nodes(...node.commands, ...node.events); + nodes.prepend(Formatting.indent()); + } else if (ast.isTransition(node)) { + const formatter = this.getNodeFormatter(node); + formatter.keyword('=>').surround(Formatting.oneSpace()); + } + } +} diff --git a/examples/statemachine/src/language-server/statemachine-module.ts b/examples/statemachine/src/language-server/statemachine-module.ts index 42601cf70..555ae2397 100644 --- a/examples/statemachine/src/language-server/statemachine-module.ts +++ b/examples/statemachine/src/language-server/statemachine-module.ts @@ -9,11 +9,17 @@ import type { LangiumServices, LangiumSharedServices, PartialLangiumServices } f import { createDefaultModule, createDefaultSharedModule, type DefaultSharedModuleContext } from 'langium/lsp'; import { StatemachineGeneratedModule, StatemachineGeneratedSharedModule } from './generated/module.js'; import { StatemachineValidator, registerValidationChecks } from './statemachine-validator.js'; +import { StatemachineCodeActionProvider } from './statemachine-code-actions.js'; +import { StatemachineFormatter } from './statemachine-formatter.js'; /** * Declaration of custom services - add your own service classes here. */ export type StatemachineAddedServices = { + lsp: { + CodeActionProvider: StatemachineCodeActionProvider; + Formatter: StatemachineFormatter; + }, validation: { StatemachineValidator: StatemachineValidator } @@ -31,6 +37,10 @@ export type StatemachineServices = LangiumServices & StatemachineAddedServices * selected services, while the custom services must be fully specified. */ export const StatemachineModule: Module = { + lsp: { + CodeActionProvider: () => new StatemachineCodeActionProvider(), + Formatter: () => new StatemachineFormatter(), + }, validation: { StatemachineValidator: () => new StatemachineValidator() } diff --git a/examples/statemachine/src/language-server/statemachine-validator.ts b/examples/statemachine/src/language-server/statemachine-validator.ts index 69fc5ed78..155709505 100644 --- a/examples/statemachine/src/language-server/statemachine-validator.ts +++ b/examples/statemachine/src/language-server/statemachine-validator.ts @@ -5,16 +5,28 @@ ******************************************************************************/ import type { ValidationAcceptor, ValidationChecks } from 'langium'; -import type { State, Statemachine, StatemachineAstType, Event } from './generated/ast.js'; +import type { State, Statemachine, StatemachineAstType, Event, Command, Transition } from './generated/ast.js'; import type { StatemachineServices } from './statemachine-module.js'; -import { MultiMap } from 'langium'; +import { MultiMap, diagnosticData } from 'langium'; + +export namespace IssueCodes { + export const StateNameUppercase = 'state-name-uppercase'; + export const UnreachedState = 'unreached-state'; + export const UnreachedCommand = 'unreached-command'; + export const UnreachedEvent = 'unreached-event'; +} export function registerValidationChecks(services: StatemachineServices) { const registry = services.validation.ValidationRegistry; const validator = services.validation.StatemachineValidator; const checks: ValidationChecks = { State: validator.checkStateNameStartsWithCapital, - Statemachine: validator.checkUniqueStatesAndEvents + Statemachine: [ + validator.checkUniqueSymbolName, + validator.checkUnreachedStates, + validator.checkUnreachedCommands, + validator.checkUnreachedEvents, + ] }; registry.register(checks, validator); } @@ -29,20 +41,24 @@ export class StatemachineValidator { if (state.name) { const firstChar = state.name.substring(0, 1); if (firstChar.toUpperCase() !== firstChar) { - accept('warning', 'State name should start with a capital letter.', { node: state, property: 'name' }); + accept('warning', 'State name should start with a capital letter.', { + node: state, + property: 'name', + data: diagnosticData(IssueCodes.StateNameUppercase), + }); } } } /** - * Checks if there are duplicate state and event names. + * Checks if there are duplicate command, event, and state names. * @param statemachine the statemachine to check * @param accept the acceptor to report errors */ - checkUniqueStatesAndEvents(statemachine: Statemachine, accept: ValidationAcceptor): void { + checkUniqueSymbolName(statemachine: Statemachine, accept: ValidationAcceptor): void { // check for duplicate state and event names and add them to the map - const names = new MultiMap(); - const allSymbols = [...statemachine.states, ...statemachine.events]; + const names = new MultiMap(); + const allSymbols = [...statemachine.commands, ...statemachine.events, ...statemachine.states]; for (const symbol of allSymbols) { names.add(symbol.name, symbol); } @@ -54,4 +70,99 @@ export class StatemachineValidator { } } } + + /** + * Checks for unreached states within the statemachine. + * @param statemachine the statemachine to check + * @param accept the acceptor to report errors + */ + checkUnreachedStates(statemachine: Statemachine, accept: ValidationAcceptor): void { + const states = new Map(); + for (const state of statemachine.states) { + states.set(state.name, state); + } + + const { ref } = statemachine.init; + if (ref && states.has(ref.name)) { + states.delete(ref.name); + this.removeStates(states, ref.transitions); + } + + for (const [name, state] of states.entries()) { + accept('hint', `Unreached state: ${name}`, { + node: state, + data: diagnosticData(IssueCodes.UnreachedState), + tags: [1], + }); + } + } + + /** + * Checks for unreached commands within the statemachine. + * @param statemachine The statemachine to check. + * @param acceptor The acceptor to report errors. + */ + checkUnreachedCommands(statemachine: Statemachine, acceptor: ValidationAcceptor): void { + const commandsByName = new Map(); + + for (const command of statemachine.commands) { + commandsByName.set(command.name, command); + } + + for (const { actions } of statemachine.states) { + for (const { ref } of actions) { + if (ref && commandsByName.has(ref.name)) { + commandsByName.delete(ref.name); + } + } + } + + for (const [name, command] of commandsByName.entries()) { + acceptor('warning', `Unreached command: ${name}`, { + node: command, + property: 'name', + data: diagnosticData(IssueCodes.UnreachedCommand), + tags: [1], + }); + } + } + + /** + * Checks for unreached evens within the statemachine. + * @param statemachine the statemachine to check + * @param accept the acceptor to report errors + */ + checkUnreachedEvents(statemachine: Statemachine, acceptor: ValidationAcceptor): void { + const eventsByName = new Map(); + + for (const event of statemachine.events) { + eventsByName.set(event.name, event); + } + + for (const { transitions } of statemachine.states) { + for (const { event: { ref: refEvent } } of transitions) { + if (refEvent && eventsByName.has(refEvent.name)) { + eventsByName.delete(refEvent.name); + } + } + } + + for (const [name, event] of eventsByName.entries()) { + acceptor('warning', `Unreached event: ${name}`, { + node: event, + property: 'name', + data: diagnosticData(IssueCodes.UnreachedEvent), + tags: [1], + }); + } + } + + private removeStates(states: Map, transitions: Transition[]): void { + for (const { state: { ref } } of transitions) { + if (ref && states.has(ref.name)) { + states.delete(ref.name); + this.removeStates(states, ref.transitions); + } + } + } }