Skip to content

Commit

Permalink
Add linters to code editor
Browse files Browse the repository at this point in the history
  • Loading branch information
emir89 committed Nov 21, 2024
1 parent 4edf55b commit b417911
Show file tree
Hide file tree
Showing 9 changed files with 420 additions and 49 deletions.
16 changes: 10 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,17 @@
"@blueprintjs/select": "^5.2.2",
"@carbon/icons": "^11.47.1",
"@carbon/react": "^1.64.1",
"@mavrin/remark-typograf": "^2.2.0",
"codemirror": "^6.0.1",
"@codemirror/legacy-modes": "^6.4.1",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lang-xml": "^6.1.0",
"xml-formatter": "^3.6.3",
"@codemirror/legacy-modes": "^6.4.1",
"@mavrin/remark-typograf": "^2.2.0",
"codemirror": "^6.0.1",
"color": "^4.2.3",
"compute-scroll-into-view": "^3.1.0",
"jshint": "^2.13.6",
"lodash": "^4.17.21",
"n3": "^1.23.0",
"re-resizable": "6.9.9",
"react": "^16.13.1",
"react-dom": "^16.13.1",
Expand All @@ -94,7 +95,8 @@
"remark-parse": "^10.0.2",
"reset-css": "^5.0.2",
"unified": "^11.0.5",
"wicg-inert": "^3.1.3"
"wicg-inert": "^3.1.3",
"xml-formatter": "^3.6.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
Expand All @@ -120,7 +122,9 @@
"@types/codemirror": "^5.60.15",
"@types/color": "^3.0.6",
"@types/jest": "^29.5.12",
"@types/jshint": "^2.12.4",
"@types/lodash": "^4.17.7",
"@types/n3": "^1.21.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
Expand Down
8 changes: 8 additions & 0 deletions src/extensions/codemirror/CodeMirror.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ BasicExample.args = {
mode: "markdown",
defaultValue: "**test me**",
};

export const LinterExample = TemplateFull.bind({});
LinterExample.args = {
name: "codeinput",
defaultValue: "**test me**",
mode: "json",
useLinting: true,
};
34 changes: 33 additions & 1 deletion src/extensions/codemirror/CodeMirror.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useRef } from "react";
import React, { useMemo, useRef } from "react";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { foldKeymap } from "@codemirror/language";
import { lintGutter } from "@codemirror/lint";
import { EditorState, Extension } from "@codemirror/state";
import { DOMEventHandlers, EditorView, KeyBinding, keymap, Rect, ViewUpdate } from "@codemirror/view";
import { minimalSetup } from "codemirror";
Expand All @@ -14,6 +15,8 @@ import {
supportedCodeEditorModes,
useCodeMirrorModeExtension,
} from "./hooks/useCodemirrorModeExtension.hooks";
import { jsLinter } from "./linters/jsLinter";
import { turtleLinter } from "./linters/turtleLinter";
//adaptations
import {
adaptedCodeFolding,
Expand All @@ -25,6 +28,7 @@ import {
adaptedLineNumbers,
adaptedPlaceholder,
} from "./tests/codemirrorTestHelper";
import { EditorMode, ExtensionCreator } from "./types";

export interface CodeEditorProps {
// Is called with the editor instance that allows access via the CodeMirror API
Expand Down Expand Up @@ -133,13 +137,22 @@ export interface CodeEditorProps {
* If the <Tab> key is enabled as normal input, i.e. it won't have the behavior of changing to the next input element, expected in a web app.
*/
enableTab?: boolean;
/**
* If the editor should use a linting feature.
*/
useLinting?: boolean;
}

const addExtensionsFor = (flag: boolean, ...extensions: Extension[]) => (flag ? [...extensions] : []);
const addToKeyMapConfigFor = (flag: boolean, ...keys: any) => (flag ? [...keys] : []);
const addHandlersFor = (flag: boolean, handlerName: string, handler: any) =>
flag ? ({ [handlerName]: handler } as DOMEventHandlers<any>) : {};

const ModeLinterMap: ReadonlyMap<EditorMode, ReadonlyArray<ExtensionCreator>> = new Map([
[EditorMode.Turtle, [turtleLinter]],
[EditorMode.JavaScript, [jsLinter]],
]);

/**
* Includes a code editor, currently we use CodeMirror library as base.
*/
Expand Down Expand Up @@ -170,9 +183,27 @@ export const CodeEditor = ({
tabForceSpaceForModes = ["python", "yaml"],
enableTab = false,
height,
useLinting = false,
}: CodeEditorProps) => {
const parent = useRef<any>(undefined);

// console.log("outerDivAttributes", outerDivAttributes);

const linters = useMemo(() => {
if (!useLinting || !mode) {
return [];
}

const values = [lintGutter()];

const linters = ModeLinterMap.get(mode as EditorMode);
if (linters) {
values.push(...linters.map((linter) => linter()));
}

return values;
}, [useLinting, mode]);

const onKeyDownHandler = (event: KeyboardEvent, view: EditorView) => {
if (onKeyDown && !onKeyDown(event)) {
if (event.key === "Enter") {
Expand Down Expand Up @@ -250,6 +281,7 @@ export const CodeEditor = ({
addExtensionsFor(shouldHighlightActiveLine, adaptedHighlightActiveLine()),
addExtensionsFor(wrapLines, EditorView?.lineWrapping),
addExtensionsFor(supportCodeFolding, adaptedFoldGutter(), adaptedCodeFolding()),
addExtensionsFor(useLinting, ...linters),
additionalExtensions,
];

Expand Down
26 changes: 26 additions & 0 deletions src/extensions/codemirror/debouncedLinter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Diagnostic } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { debounce } from "lodash";

import { Linter } from "./types";

const DEBOUNCE_TIME = 500;

export const debouncedLinter = (lintFunction: Linter, time = DEBOUNCE_TIME) => {
const debouncedFn = debounce(
(
view: EditorView,
resolve: (diagnostics: ReadonlyArray<Diagnostic> | Promise<ReadonlyArray<Diagnostic>>) => void
) => {
const diagnostics = lintFunction(view);
resolve(diagnostics);
},
time
);

return (view: EditorView) => {
return new Promise<ReadonlyArray<Diagnostic>>((resolve) => {
debouncedFn(view, resolve);
});
};
};
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { defaultHighlightStyle, StreamLanguage, StreamParser, LanguageSupport } from "@codemirror/language";

import { json } from "@codemirror/lang-json";
//modes imports
import { markdown } from "@codemirror/lang-markdown";
import { json } from "@codemirror/lang-json";
import { xml } from "@codemirror/lang-xml";
import { defaultHighlightStyle, LanguageSupport, StreamLanguage, StreamParser } from "@codemirror/language";
import { javascript } from "@codemirror/legacy-modes/mode/javascript";
import { jinja2 } from "@codemirror/legacy-modes/mode/jinja2";
import { mathematica } from "@codemirror/legacy-modes/mode/mathematica";
import { ntriples } from "@codemirror/legacy-modes/mode/ntriples";
import { python } from "@codemirror/legacy-modes/mode/python";
import { sparql } from "@codemirror/legacy-modes/mode/sparql";
import { sql } from "@codemirror/legacy-modes/mode/sql";
import { turtle } from "@codemirror/legacy-modes/mode/turtle";
import { jinja2 } from "@codemirror/legacy-modes/mode/jinja2";
import { yaml } from "@codemirror/legacy-modes/mode/yaml";
import { ntriples } from "@codemirror/legacy-modes/mode/ntriples";
import { mathematica } from "@codemirror/legacy-modes/mode/mathematica";

//adaptations
import { adaptedSyntaxHighlighting } from "../tests/codemirrorTestHelper";
Expand All @@ -39,6 +38,6 @@ export const useCodeMirrorModeExtension = (mode?: SupportedCodeEditorModes) => {
return !mode
? adaptedSyntaxHighlighting(defaultHighlightStyle)
: ["json", "markdown", "xml"].includes(mode)
? ((typeof supportedModes[mode] === "function" ? supportedModes[mode] : () => {}) as () => LanguageSupport)()
? ((typeof supportedModes[mode] === "function" ? supportedModes[mode] : () => null) as () => LanguageSupport)()
: StreamLanguage?.define(supportedModes[mode] as StreamParser<unknown>);
};
38 changes: 38 additions & 0 deletions src/extensions/codemirror/linters/jsLinter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Diagnostic, linter } from "@codemirror/lint";
import { JSHINT as jshint } from "jshint";

import { ExtensionCreator } from "../types";

const lintOptions = {
esversion: 11,
browser: true,
};

/**
* Sets up the javascript linter. Documentation: https://codemirror.net/examples/lint/
*/
export const jsLinter: ExtensionCreator = () => {
return linter((view) => {
const diagnostics: Array<Diagnostic> = [];
const codeText = view.state.doc.toJSON();
jshint(codeText, lintOptions);
const errors = jshint?.data()?.errors;

if (errors && errors.length > 0) {
errors.forEach((error) => {
const selectedLine = view.state.doc.line(error.line);

const diagnostic: Diagnostic = {
from: selectedLine.from,
to: selectedLine.to,
severity: "error",
message: error.reason,
};

diagnostics.push(diagnostic);
});
}

return diagnostics;
});
};
102 changes: 102 additions & 0 deletions src/extensions/codemirror/linters/turtleLinter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Diagnostic, linter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { Parser } from "n3";

import { debouncedLinter } from "../debouncedLinter";
import { ExtensionCreator, Linter } from "../types";

const parser = new Parser();

const EMPTY_RESOURCE = "<>";

const getError = (message: string, view: EditorView) => {
const lineMatch = message.match(/(?<=line )\d{1,}/);
const valueMatch = message.match(/"([^"]*)"/);

const lineNumber = lineMatch ? Number(lineMatch[0]) : 1;
// the [1] index is used to get the caputre group
const errorContent = valueMatch && valueMatch[1];

const line = view.state.doc.line(lineNumber);
const position = line.text.search(errorContent ?? /\S/);

const from = line.from + position;
const errorLength = errorContent?.length;

return { from, to: errorLength ? from + errorLength : line.to };
};

const getQuadError = (view: EditorView) => {
const lines = view.state.doc.toJSON();

for (let i = 0; i < lines.length; i += 1) {
const input = lines[i].trim();

if (!input) {
continue;
}

if (input.includes(EMPTY_RESOURCE)) {
// i + 1 is used here because the codemirror uses 1-indexes
const line = view.state.doc.line(i + 1);
const position = line.text.search(EMPTY_RESOURCE);

const from = line.from + position;

return {
from,
to: from + EMPTY_RESOURCE.length,
};
}
}

return { from: 0, to: view.state.doc.length };
};

const n3Linter: Linter = (view) => {
const diagnostics: Array<Diagnostic> = [];
const value = view.state.doc.toString();

try {
const quads = parser.parse(value);

quads.forEach((quad) => {
if (!quad.subject || !quad.predicate || !quad.object) {
const { from, to } = getQuadError(view);

view.dispatch({
scrollIntoView: true,
});

diagnostics.push({
from,
to,
severity: "error",
message: `Invalid RDF quad:\n\nsubject: ${quad.subject}\npredicate: ${quad.predicate}\nobject: ${quad.object}`,
});
}
});
} catch (error) {
const { message } = error as Error;

const { from, to } = getError(message, view);

view.dispatch({
scrollIntoView: true,
});

diagnostics.push({
from,
to,
severity: "error",
message: (error as Error).message,
});
}

return diagnostics;
};

/**
* Sets up the turtle linter. Documentation: https://codemirror.net/examples/lint/
*/
export const turtleLinter: ExtensionCreator = () => linter(debouncedLinter(n3Linter));
13 changes: 13 additions & 0 deletions src/extensions/codemirror/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Diagnostic } from "@codemirror/lint";
import { Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";

export type Linter = (view: EditorView) => ReadonlyArray<Diagnostic> | Promise<ReadonlyArray<Diagnostic>>;

export enum EditorMode {
Turtle = "turtle",
SPARQL = "sparql",
JavaScript = "javascript",
}

export type ExtensionCreator<T = unknown> = (options?: T) => Extension;
Loading

0 comments on commit b417911

Please sign in to comment.