Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto completion and linting for printer.cfg #1457

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ package-lock.json
cypress/screenshots/
cypress/videos/
components.d.ts

src/plugins/Codemirror/KlipperCfgLang/parser/klipperCfgParser.js
src/plugins/Codemirror/KlipperCfgLang/parser/klipperCfgParser.terms.js
1,035 changes: 971 additions & 64 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
},
"scripts": {
"serve": "vite serve",
"build": "vite build && npm run build.zip",
"build": "npm run build:lang && vite build && npm run build.zip",
"build:lang": "npm run build:parser:klipperCfg && npm run build:lang:klipperCfg",
"build:lang:klipperCfg": "rollup --config src/plugins/Codemirror/KlipperCfgLang/rollup.config.js",
"build:parser:klipperCfg": "lezer-generator src/plugins/Codemirror/KlipperCfgLang/parser/klipperCfg.grammar -o src/plugins/Codemirror/KlipperCfgLang/parser/klipperCfgParser.js",
"format": "npm run format:base -- --write",
"format:base": "prettier .",
"format:check": "npm run format:base -- --check",
Expand All @@ -20,6 +23,8 @@
"start": "vite build && vite preview",
"test": "start-server-and-test preview http://127.0.0.1:4173/ 'cypress run'",
"test:ui": "cypress open",
"test:parser:klipperCfg": "mocha --config src/plugins/Codemirror/.mocharc.json src/plugins/Codemirror/KlipperCfgLang/testParser/testKlipperCfgParser.js",
"test:lint:klipperCfg": "mocha --config src/plugins/Codemirror/.mocharc.json src/plugins/Codemirror/KlipperCfgLang/testLint/testKlipperCfgLint.js",
"changelog": "git cliff v0.0.4..$(git describe --tags $(git rev-list --tags --max-count=1)) --output CHANGELOG.md"
},
"dependencies": {
Expand All @@ -45,6 +50,7 @@
"hls.js": "^1.3.3",
"jmuxer": "^2.0.5",
"js-sha256": "^0.9.0",
"lezer": "^0.13.5",
"lodash.kebabcase": "^4.1.1",
"lodash.throttle": "^4.1.1",
"overlayscrollbars": "^1.13.1",
Expand All @@ -70,7 +76,9 @@
},
"devDependencies": {
"@intlify/vite-plugin-vue-i18n": "^2.5.0",
"@lezer/generator": "^1.3.0",
"@mdi/js": "^7.0.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@types/file-saver": "^2.0.5",
"@types/jmuxer": "^2.0.3",
"@types/lodash.kebabcase": "^4.1.6",
Expand All @@ -86,9 +94,12 @@
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-jsonc": "^2.2.1",
"eslint-plugin-vue": "^9.0.0",
"mocha": "^10.2.0",
"prettier": "^2.5.1",
"rollup": "^2.79.1",
"sass": "~1.32",
"start-server-and-test": "^1.14.0",
"ts-node": "^10.9.1",
"typescript": "^4.5.5",
"unplugin-vue-components": "^0.22.12",
"vite": "^3.2.7",
Expand Down
18 changes: 16 additions & 2 deletions src/components/inputs/Codemirror.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ import { EditorView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { vscodeDark } from '@uiw/codemirror-theme-vscode'
import { StreamLanguage } from '@codemirror/language'
import { klipper_config } from '@/plugins/StreamParserKlipperConfig'
import { gcode } from '@/plugins/StreamParserGcode'
import { indentWithTab } from '@codemirror/commands'
import { json } from '@codemirror/lang-json'
import { css } from '@codemirror/lang-css'
import { klipperCfg } from '../../plugins/Codemirror/KlipperCfgLang/lang/klipperCfg'
import { parseErrorLint } from '../../plugins/Codemirror/parseErrorLint'
import { klipperCfgLint } from '../../plugins/Codemirror/KlipperCfgLang/lang/lint'
import { indentUnit } from '@codemirror/language'

// for lezer grammar debugging
/* import { logTree } from '../../plugins/Codemirror/printLezerTree'
import { syntaxTree } from '@codemirror/language'
import { parser } from '../../plugins/Codemirror/KlipperCfgLang/dist/klipperCfgParser.es.js' */

@Component
export default class Codemirror extends Mixins(BaseMixin) {
private content = ''
Expand Down Expand Up @@ -49,6 +56,11 @@ export default class Codemirror extends Mixins(BaseMixin) {
if (newVal !== cm_value) {
this.setCmValue(newVal)
}
// for lezer grammar debugging
/* const state = this.cminstance?.state ?? EditorState.create({})
logTree(syntaxTree(state), state.doc.toString())
const text = state.doc.toString()
console.log(parser.parse(text) + '') */
}

mounted(): void {
Expand Down Expand Up @@ -86,6 +98,7 @@ export default class Codemirror extends Mixins(BaseMixin) {
basicSetup,
vscodeDark,
indentUnit.of(' '.repeat(this.tabSize)),
parseErrorLint,
keymap.of([indentWithTab]),
EditorView.updateListener.of((update) => {
this.content = update.state?.doc.toString()
Expand All @@ -95,7 +108,8 @@ export default class Codemirror extends Mixins(BaseMixin) {
}),
]

if (['cfg', 'conf'].includes(this.fileExtension)) extensions.push(StreamLanguage.define(klipper_config))
if ("printer.cfg" === this.name) extensions.push(klipperCfgLint)
if (['cfg', 'conf'].includes(this.fileExtension)) extensions.push(klipperCfg())
else if (['gcode'].includes(this.fileExtension)) extensions.push(StreamLanguage.define(gcode))
else if (['json'].includes(this.fileExtension)) extensions.push(json())
else if (['css', 'scss', 'sass'].includes(this.fileExtension)) extensions.push(css())
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/Codemirror/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extensions": ["ts"],
"node-option": ["loader=ts-node/esm"]
}
184 changes: 184 additions & 0 deletions src/plugins/Codemirror/KlipperCfgLang/lang/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { CompletionContext } from '@codemirror/autocomplete'
import { syntaxTree } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { parseCfgMd } from '../parserCfgMd/parserCfgMd.js'
import { exampleText } from '../parserCfgMd/ref.js'

// Parse Cfg Reference
const [parsedMd, dependentParameters] = parseCfgMd(exampleText)

// Map with all autocompletion objects for each blocktype
const autocompletionMap = new Map<string, { label: string; type: string; info: string }[]>()

// Map with all autocompletion objects for each trigger parameter
const dependentParametersMap = new Map<string, { label: string; type: string; info: string }[]>()

function createParameterObject(parameter: any) {
return {
label: `${parameter.name}: `,
type: 'variable',
detail: parameter.isOptional ? '(optional)' : '(required)',
info: parameter.tooltip,
}
}

// Populate autocompletionMap
parsedMd.forEach((entry) => {
const parameters = entry.parameters.map(createParameterObject)
autocompletionMap.set(entry.type, parameters)
})

// Populate dependentParametersMap
dependentParameters.forEach((entry) => {
const parameters = entry.parameters.map(createParameterObject)
dependentParametersMap.set(entry.triggerParameter, parameters)
})

export function klipperCfgCompletionSource(context: CompletionContext) {
const parent = syntaxTree(context.state).resolveInner(context.pos, -1)
const tagBefore = getTagBefore(context.state, parent.from, context.pos)

if (parent?.type.name === 'Comment') return null

if (parent?.type.name === 'Parameter') {
const typeNode = findTypeNode(parent)
if (!typeNode) return null
const blockType = context.state.sliceDoc(typeNode.from, typeNode.to)
const options = getOptionsByBlockType(blockType, context.state, parent)
if (options == null) return null

return {
from: tagBefore ? parent.from + tagBefore.index : context.pos,
options: options,
validFor: /^(\w*)?$/,
}
}

if (parent?.parent?.type.name === 'CfgBlock') {
return {
from: tagBefore ? parent.from + tagBefore.index : context.pos,
options: getAllPossibleBlockTypes(context.state, parent),
validFor: /^(\w*)?$/,
}
}

return null
}

function getTagBefore(state: EditorState, from: number, pos: number) {
const textBefore = state.sliceDoc(from, pos)
return /\w*$/.exec(textBefore)
}

function findTypeNode(node: SyntaxNode) {
let travNode: SyntaxNode | null = node
while (travNode) {
if (travNode.type.name === 'CfgBlock') {
return travNode.firstChild
}
travNode = travNode.parent
}
return null
}

function findPrinterNode(node: SyntaxNode, state: EditorState) {
const typeNode = findTypeNode(node)
// If node is [printer] return it
if (typeNode && typeNode.type.name === 'printer') {
return typeNode
}
// If not, find Programm node and search for printer node from there
let programmNode
if (typeNode) programmNode = typeNode.parent?.parent ?? null // If node is a typeNode go up to Programm node
else programmNode = node // If typeNode is null, node must be the Programm node or inside inport (here not important)
const printerNode =
programmNode?.getChildren('CfgBlock')?.find((cfgBlockNode) => {
const blockTypeNode = cfgBlockNode.firstChild
if (!blockTypeNode) return false
return state.sliceDoc(blockTypeNode.from, blockTypeNode.to) === 'printer'
}) ?? null
return printerNode
}

function getPrinterKinematics(state: EditorState, node: SyntaxNode) {
const printerOptions = findPrinterNode(node, state)?.getChild('Body')?.getChildren('Option') ?? []
for (const childNode of printerOptions) {
const parameter = childNode.getChild('Parameter')
const value = childNode.getChild('Value')
if (!parameter || !value) continue
const parameterName = state.sliceDoc(parameter.from, parameter.to)
if (parameterName !== 'kinematics') continue
const printerKinematics = state.sliceDoc(value.from, value.to).replace(/(\r\n|\n|\r)/gm, '')
return printerKinematics.split('#')[0].trim()
}
return ''
}

function getOptionsByBlockType(blocktype: string, state: EditorState, parentNode: SyntaxNode) {
let options: { label: string; type: string; info: string }[] = []
const printerKinematics = getPrinterKinematics(state, parentNode)
if (blocktype.includes('stepper_')) {
1
const stepperOptions = autocompletionMap.get(blocktype + '--' + printerKinematics)
const secondaryStepperOptions = /\d/.test(blocktype)
? autocompletionMap.get('stepper_z1')
: autocompletionMap.get('stepper_x')
if (stepperOptions) {
options.push(...stepperOptions)
}
if (secondaryStepperOptions) {
options.push(...secondaryStepperOptions)
}
} else {
const optionsForBlockType =
autocompletionMap.get(blocktype) || autocompletionMap.get(blocktype + '--' + printerKinematics)
if (optionsForBlockType) {
options.push(...optionsForBlockType)
}
}
if (blocktype.includes('extruder') && /\d/.test(blocktype)) {
const secondaryExtruderOptions = autocompletionMap.get('extruder1')
if (secondaryExtruderOptions) {
options.push(...secondaryExtruderOptions)
}
}
return editOptions(options, state, parentNode)
}

function editOptions(options: { label: string; type: string; info: string }[], state: EditorState, node: SyntaxNode) {
const allreadyUsedOptions = new Set<string>()
// for all options in the current cfg block check if it is a trigger parameters and add dependent parameters if necessary
for (const childNode of node.parent?.parent?.getChildren('Option') ?? []) {
const parameter = childNode.firstChild
const value = childNode.lastChild
if (!parameter || !value) continue
const parameterName = state.sliceDoc(parameter.from, parameter.to).trim()
if (!parameterName.endsWith('_')) allreadyUsedOptions.add(parameterName) // save allready used options to remove them later (not "variable_")
const valueName = state.sliceDoc(value.from, value.to).replace(/(\r\n|\n|\r)/gm, '')
const parameterValue = parameterName + ':' + valueName
const mapEntry = dependentParametersMap.get(parameterValue)
if (mapEntry) {
options = options.concat(mapEntry)
}
}
// remove all options that are already used in the current cfg block
return (options = options.filter((option) => !allreadyUsedOptions.has(option.label.replace(': ', ''))))
}

function getAllPossibleBlockTypes(state: EditorState, node: SyntaxNode) {
const printerKinematics = getPrinterKinematics(state, node)
let blockTypes = Array.from(autocompletionMap.keys())
if (printerKinematics !== '') {
// all blockTypes but if stepper_ only these which match the printerKinematics
blockTypes = blockTypes.filter(
(blockType) => !blockType.includes('stepper_') || blockType.includes('--' + printerKinematics)
)
}
return blockTypes.map((blockType) => ({
label: blockType.includes('--') ? blockType.split('-')[0] : blockType,
type: 'keyword',
}))
}

//Known Issues: secondary stepper/extruder-names are not suggested (only stepper_z1/extruder1)
45 changes: 45 additions & 0 deletions src/plugins/Codemirror/KlipperCfgLang/lang/klipperCfg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { parser } from '../dist/klipperCfgParser.es.js'
import { LRLanguage, LanguageSupport, StreamLanguage, foldNodeProp } from '@codemirror/language'
import { parseMixed } from '@lezer/common'
import { klipper_config } from '../../../StreamParserKlipperConfig.js'
import { klipperCfgCompletionSource } from './complete.js'


const jinja2Parser = StreamLanguage.define(klipper_config).parser

export const klipperCfgLang = LRLanguage.define({
parser: parser.configure({
props: [
foldNodeProp.add({
ConfigBlock(tree) {
const body = tree.lastChild
if (body == null) return null
let lastOption = body.lastChild
if (lastOption == null) return null
while (lastOption.name == 'Comment') {
lastOption = lastOption.prevSibling
if (lastOption == null) return null
}
return { from: body.from - 1, to: lastOption.to - 1 }
},
}),
],
wrap: parseMixed((node) => {
return node.name == 'Jinja2' ? { parser: jinja2Parser } : null
}),
}),
languageData: {
commentTokens: { line: '#' },
},
})

export function klipperCfg() {
return new LanguageSupport(klipperCfgLang, [
klipperCfgLang.data.of({ autocomplete: klipperCfgCompletionSource }),
])
}

/*
to generate the parser run:
npx lezer-generator klipperCfg.grammar -o klipperCfgParser.js
*/
Loading