Skip to content

Commit

Permalink
feat: add error linting to the editor (#203)
Browse files Browse the repository at this point in the history
* fix: autocomplete substitute the entire line with correct case sensitive

* feat: add first error linting test version

* fix: remove unused dependencies from grammar package.json

* fix(grammar): install @codemirror/lint

* fix: allow permutations of parameters in 'send'

* refactor(grammar): move lint part in a separate file

* fix: slangroom comment char

* feat: add linting with similarity suggestions for all slangroom statements

* feat: add also plugin name to complete statements for easier match on plugin name

* fix: slangroom comment char

* feat: add autocomplete based on plugin suggestion

* feat: add scenario to allowed lines

---------

Co-authored-by: matteo-cristino <[email protected]>
Co-authored-by: Matteo Cristino <[email protected]>
  • Loading branch information
3 people authored Oct 2, 2024
1 parent e93af98 commit fa74b29
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 55 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,6 @@ node_modules
!/grammar/src/syntax.grammar.terms.d.ts
!/grammar/src/tokens.js
!/grammar/src/complete.ts
!/grammar/src/lint.ts
!/grammar/utils/package.json
!/grammar/utils/prepare_complete.mjs
10 changes: 6 additions & 4 deletions grammar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.8.1",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
"@lezer/lr": "^1.0.0",
"fastest-levenshtein": "^1.0.16"
},
"devDependencies": {
"@lezer/generator": "^1.0.0",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-typescript": "^11.1.6",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.0",
"@semantic-release/git": "^10.0.1",
Expand All @@ -44,9 +48,7 @@
"rollup-plugin-dts": "^4.0.1",
"semantic-release": "^24.1.0",
"semantic-release-commit-filter": "^1.0.2",
"typescript": "^4.3.4",
"@rollup/plugin-typescript": "^11.1.6",
"@rollup/plugin-json": "^6.0.0"
"typescript": "^4.3.4"
},
"license": "AGPL-3.0-or-later"
}
57 changes: 28 additions & 29 deletions grammar/src/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,46 @@ function stripQuotes(s: string) {
const fullStatementSnippets = fullStatementTemplates.map((x) => {
let n = 1;
return snippetCompletion(
x.label.replace(/''/g, () => `'\${${n++}:}'`),
x.displayLabel.replace(/''/g, () => `'\${${n++}:}'`),
x,
);
});

export function completeStatement(context: CompletionContext) {
const line = context.state.doc.lineAt(context.pos);
let textBefore = context.state.sliceDoc(line.from, context.pos);
const triggerMatch = /^[GT].*$/i.exec(textBefore);

if (triggerMatch) {
const strings = textBefore.match(/'([^']*)'/g);
textBefore = textBefore.toLowerCase();
if (!strings) {
return {
from: context.pos - triggerMatch[0].length,
options: fullStatementSnippets,
validFor: /^.*$/,
};
}

const strippedStrings = strings.map(stripQuotes);

const templateOption = fullStatementTemplates.map((x) => {
let n = 1;
let m = 0;
return snippetCompletion(
x.label.replace(/''/g, () => `'\${${n}:${strippedStrings[n++ - 1] || ''}}'`),
{
label: x.label.replace(/''/g, () => `${strings[m++] || "''"}`),
type: x.type,
},
);
});

const strings = textBefore.match(/'([^']*)'/g);
textBefore = textBefore.toLowerCase();
if (!strings) {
return {
from: context.pos - textBefore.length,
options: templateOption,
to : line.to,
options: fullStatementSnippets,
validFor: /^.*$/,
};
}

return null;
const strippedStrings = strings.map(stripQuotes);

const templateOption = fullStatementTemplates.map((x) => {
let n = 1;
let m = 0;
return snippetCompletion(
x.displayLabel.replace(/''/g, () => `'\${${n}:${strippedStrings[n++ - 1] || ''}}'`),
{
label: x.label.replace(/''/g, () => `${strings[m] || "''"}`),
displayLabel: x.displayLabel.replace(/''/g, () => `${strings[m++] || "''"}`),
type: x.type,
},
);
});

return {
from: context.pos - textBefore.length,
to : line.to,
options: templateOption,
validFor: /^.*$/,
};

}
4 changes: 3 additions & 1 deletion grammar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
import { styleTags, tags as t } from '@lezer/highlight';
import { completeStatement } from './complete';

export { customLinter } from './lint';

const syntax_colors = syntaxHighlighting(
HighlightStyle.define(
[
Expand Down Expand Up @@ -53,4 +55,4 @@ const ac = SlangroomLanguage.data.of({

export function Slangroom() {
return new LanguageSupport(SlangroomLanguage, [syntax_colors, ac]);
}
};
139 changes: 139 additions & 0 deletions grammar/src/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2024 Dyne.org foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import { linter, Diagnostic } from "@codemirror/lint";
import { fullStatementTemplates } from "./complete_statement";
import { distance } from "fastest-levenshtein"

// Helper function to extract the content inside quotes
function extractContent(line: string): string[] {
return [...line.matchAll(/'([^']*)'/g)].map(match => match[1]);
}

// Helper function to insert the content into the template
function insertContent(template: string, content: string[]): string {
let contentIndex = 0;
return template.replace(/''/g, () => `'${content[contentIndex++] || ""}'`);
}

// Normalize a line (case-insensitive, remove extra spaces)
function normalizeLine(line: string): string {

const regex = /'[^']*'|[^'\s]+/g;

return line.match(regex)?.map((word) => {
if (word.startsWith("'") && word.endsWith("'")) {
return word;
}
if (word === 'I' || word === 'i') {
return word;
}
return word.toLowerCase();
}).join(' ').trim() || '';
}

// Helper function to generate permutations of "send" phrases
function generateSendPermutations(statement: string): string[] {
const sendParts = statement.match(/send [^']+ ''/g) || [];
if (sendParts.length <= 1) return [statement];

const permute = (arr: string[], result: string[] = []) => {
if (arr.length === 0) return [result.join(' and ')];
let permutations: string[] = [];
for (let i = 0; i < arr.length; i++) {
const current = arr.slice();
const next = current.splice(i, 1);
permutations = permutations.concat(permute(current, result.concat(next)));
}
return permutations;
};

const permutedSendParts = permute(sendParts);
return permutedSendParts.map((perm) =>
statement.replace(/(send [^']+ '')( and send [^']+ '')*/g, perm),
);
}


function capitalize(statement: string): string {
return statement.charAt(0).toUpperCase() + statement.slice(1);
}

// Helper function to find the most similar correct statements
function findMostSimilarStatements(wrongStatement: string, correctStatements: string[]): string[] {
const scores = correctStatements.map(template => {
const normalizedTemplate = normalizeLine(template);
return {
statement: template,
distance: distance(normalizedTemplate, wrongStatement)
};
});

// Sort by similarity (smallest Levenshtein distance)
scores.sort((a, b) => a.distance - b.distance);

// Return the top 3 most similar statements
return scores.slice(0, 3).map(score => capitalize(score.statement));
}

const correctStatements = fullStatementTemplates.flatMap(template => {
const normalizedTemplate = normalizeLine(template.displayLabel);
return generateSendPermutations(normalizedTemplate);
});


export const customLinter = linter((view) => {
let diagnostics: Diagnostic[] = [];
const doc = view.state.doc;
const cursorLine = view.state.selection.main.head;
const lineCount = doc.lines;

for (let i = 1; i <= lineCount; i++) {
const line = doc.line(i);

// Ignore the line where the cursor is
if (line.from <= cursorLine && cursorLine <= line.to) {
continue;
}

const lineText = line.text.trim();

// Ignore empty lines and comment lines
if (lineText === '' || lineText.startsWith('#') || lineText.toLowerCase().startsWith('scenario')) {
continue;
}

const content = extractContent(lineText);

const normalizedLine = normalizeLine(lineText);
const modifiedStatements = correctStatements.map(template => {
const updatedTemplate = insertContent(template, content);
return updatedTemplate;
});

const matchesStatement = modifiedStatements.includes(normalizedLine);

if (!matchesStatement) {
const mostSimilarStatements = findMostSimilarStatements(normalizedLine, modifiedStatements);

diagnostics.push({
from: line.from,
to: line.to,
severity: 'error',
message: 'Invalid statement, do you mean:',
actions: mostSimilarStatements.map((statement) => ({
name: statement,
apply(view, from, to) {
view.dispatch({
changes: { from, to, insert: statement },
});
},
})),
});
}
}

return diagnostics;
});

43 changes: 22 additions & 21 deletions grammar/utils/prepare_complete.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import { zencode } from "@slangroom/zencode";

const fullStatementTemplates = [];

const generateStatements = (plugin) => {
const generateStatements = (nameAndPlugin) => {
const [name, plugin] = nameAndPlugin;
const p = new Slangroom(plugin).getPlugin()
p.forEach(([k]) => {
let openConnect = '';
Expand All @@ -42,30 +43,30 @@ const generateStatements = (plugin) => {
const statement = `I ${openConnect}${params}${k.phrase}`;
const lowerCaseStatement = `I ${openConnect}${params.toLowerCase()}${k.phrase.toLowerCase()}`;
fullStatementTemplates.push(
{ label: `given ${lowerCaseStatement}`, displayLabel:`Given ${statement}`, apply: `Given ${statement}`, type: "keyword" },
{ label: `then ${lowerCaseStatement}`, displayLabel: `Then ${statement}`, apply: `Then ${statement}`, type: "keyword" },
{ label: `given ${lowerCaseStatement} and output into ''`, displayLabel: `Given ${statement} and output into ''`, apply: `Given ${statement} and output into ''`, type: "keyword" },
{ label: `then ${lowerCaseStatement} and output into ''`, displayLabel: `Then ${statement} and output into ''`, apply: `Then ${statement} and output into ''`, type: "keyword" }
{ label: `${name} given ${lowerCaseStatement}`, displayLabel:`Given ${statement}`, type: "keyword", info: `[${name}]` },
{ label: `${name} then ${lowerCaseStatement}`, displayLabel: `Then ${statement}`, type: "keyword", info: `[${name}]` },
{ label: `${name} given ${lowerCaseStatement} and output into ''`, displayLabel: `Given ${statement} and output into ''`, type: "keyword", info: `[${name}]` },
{ label: `${name} then ${lowerCaseStatement} and output into ''`, displayLabel: `Then ${statement} and output into ''`, type: "keyword", info: `[${name}]` }
);
});
}

[
db,
ethereum,
fs,
git,
helpers,
http,
JSONSchema,
oauth,
pocketbase,
qrcode,
redis,
shell,
timestamp,
wallet,
zencode
].map(x => generateStatements(x))
['db', db],
['ethereum', ethereum],
['fs', fs],
['git', git],
['helpers', helpers],
['http', http],
['JSONSchema', JSONSchema],
['oauth', oauth],
['pocketbase', pocketbase],
['qrcode', qrcode],
['redis', redis],
['shell', shell],
['timestamp', timestamp],
['wallet', wallet],
['zencode', zencode]
].map((x) => generateStatements(x))

await pfs.writeFile('../src/complete_statement.ts', `export const fullStatementTemplates = ${JSON.stringify(fullStatementTemplates, null, 4)}`, 'utf-8')
Loading

0 comments on commit fa74b29

Please sign in to comment.