diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index e62316e1cb8a..994561e7c318 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -10,22 +10,26 @@ import { getEslintJsonSchema, getTypescriptJsonSchema, } from '../lib/jsonSchema'; -import { - parseESLintRC, - parseTSConfig, - tryParseEslintModule, -} from '../lib/parseConfig'; +import { parseTSConfig, tryParseEslintModule } from '../lib/parseConfig'; import type { LintCodeAction } from '../linter/utils'; import { parseLintResults, parseMarkers } from '../linter/utils'; -import type { WebLinter } from '../linter/WebLinter'; import type { TabType } from '../types'; import { createProvideCodeActions } from './createProvideCodeActions'; import type { CommonEditorProps } from './types'; -import type { SandboxInstance } from './useSandboxServices'; +import type { SandboxServices } from './useSandboxServices'; + +export type LoadedEditorProps = CommonEditorProps & SandboxServices; -export interface LoadedEditorProps extends CommonEditorProps { - readonly sandboxInstance: SandboxInstance; - readonly webLinter: WebLinter; +function applyEdit( + model: Monaco.editor.ITextModel, + editor: Monaco.editor.ICodeEditor, + edit: Monaco.editor.IIdentifiedSingleEditOperation, +): void { + if (model.isAttachedToEditor()) { + editor.executeEdits('eslint', [edit]); + } else { + model.pushEditOperations([], [edit], () => null); + } } export const LoadedEditor: React.FC = ({ @@ -40,8 +44,9 @@ export const LoadedEditor: React.FC = ({ onMarkersChange, onChange, onSelect, - sandboxInstance, + sandboxInstance: { editor, monaco }, showAST, + system, sourceType, webLinter, activeTab, @@ -52,16 +57,16 @@ export const LoadedEditor: React.FC = ({ const codeActions = useRef(new Map()).current; const [tabs] = useState>(() => { const tabsDefault = { - code: sandboxInstance.editor.getModel()!, - tsconfig: sandboxInstance.monaco.editor.createModel( + code: editor.getModel()!, + tsconfig: monaco.editor.createModel( tsconfig, 'json', - sandboxInstance.monaco.Uri.file('/tsconfig.json'), + monaco.Uri.file('/tsconfig.json'), ), - eslintrc: sandboxInstance.monaco.editor.createModel( + eslintrc: monaco.editor.createModel( eslintrc, 'json', - sandboxInstance.monaco.Uri.file('/.eslintrc'), + monaco.Uri.file('/.eslintrc'), ), }; tabsDefault.code.updateOptions({ tabSize: 2, insertSpaces: true }); @@ -71,289 +76,229 @@ export const LoadedEditor: React.FC = ({ }); const updateMarkers = useCallback(() => { - const model = sandboxInstance.editor.getModel()!; - const markers = sandboxInstance.monaco.editor.getModelMarkers({ + const model = editor.getModel()!; + const markers = monaco.editor.getModelMarkers({ resource: model.uri, }); - onMarkersChange(parseMarkers(markers, codeActions, sandboxInstance.editor)); - }, [ - codeActions, - onMarkersChange, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - ]); + onMarkersChange(parseMarkers(markers, codeActions, editor)); + }, [codeActions, onMarkersChange, editor, monaco.editor]); + + useEffect(() => { + webLinter.updateParserOptions(sourceType); + }, [webLinter, sourceType]); useEffect(() => { const newPath = `/input${fileType}`; if (tabs.code.uri.path !== newPath) { - const newModel = sandboxInstance.monaco.editor.createModel( - tabs.code.getValue(), + const code = tabs.code.getValue(); + const newModel = monaco.editor.createModel( + code, undefined, - sandboxInstance.monaco.Uri.file(newPath), + monaco.Uri.file(newPath), ); newModel.updateOptions({ tabSize: 2, insertSpaces: true }); if (tabs.code.isAttachedToEditor()) { - sandboxInstance.editor.setModel(newModel); + editor.setModel(newModel); } tabs.code.dispose(); tabs.code = newModel; + system.writeFile(newPath, code); } - }, [fileType, sandboxInstance.editor, sandboxInstance.monaco, tabs]); + }, [fileType, editor, system, monaco, tabs]); useEffect(() => { const config = createCompilerOptions( parseTSConfig(tsconfig).compilerOptions, ); - webLinter.updateCompilerOptions(config); - sandboxInstance.setCompilerSettings( + monaco.languages.typescript.typescriptDefaults.setCompilerOptions( config as Monaco.languages.typescript.CompilerOptions, ); - }, [sandboxInstance, tsconfig, webLinter]); + }, [monaco, tsconfig]); useEffect(() => { - webLinter.updateEslintConfig(parseESLintRC(eslintrc)); - }, [eslintrc, webLinter]); + if (editor.getModel()?.uri.path !== tabs[activeTab].uri.path) { + editor.setModel(tabs[activeTab]); + updateMarkers(); + } + }, [activeTab, editor, tabs, updateMarkers]); useEffect(() => { - sandboxInstance.editor.setModel(tabs[activeTab]); - updateMarkers(); - }, [activeTab, sandboxInstance.editor, tabs, updateMarkers]); + const disposable = webLinter.onLint((uri, messages) => { + const diagnostics = parseLintResults(messages, codeActions, ruleId => + monaco.Uri.parse(webLinter.rules.get(ruleId)?.url ?? ''), + ); + monaco.editor.setModelMarkers( + monaco.editor.getModel(monaco.Uri.file(uri))!, + 'eslint', + diagnostics, + ); + updateMarkers(); + }); + return () => disposable(); + }, [webLinter, monaco, codeActions, updateMarkers]); useEffect(() => { - const lintEditor = debounce(() => { - console.info('[Editor] linting triggered'); - - webLinter.updateParserOptions(sourceType); - - try { - const messages = webLinter.lint(code, tabs.code.uri.path); - - const markers = parseLintResults(messages, codeActions, ruleId => - sandboxInstance.monaco.Uri.parse( - webLinter.rulesMap.get(ruleId)?.url ?? '', - ), - ); - - sandboxInstance.monaco.editor.setModelMarkers( - tabs.code, - 'eslint', - markers, - ); - - // fallback when event is not preset, ts < 4.0.5 - if (!sandboxInstance.monaco.editor.onDidChangeMarkers) { - updateMarkers(); - } - } catch (e) { - onMarkersChange(e as Error); - } - - onEsASTChange(webLinter.storedAST); - onTsASTChange(webLinter.storedTsAST); - onScopeChange(webLinter.storedScope); - - const position = sandboxInstance.editor.getPosition(); - onSelect(position ? tabs.code.getOffsetAt(position) : undefined); - }, 500); - - lintEditor(); - }, [ - code, - fileType, - tsconfig, - eslintrc, - sourceType, - webLinter, - onEsASTChange, - onTsASTChange, - onScopeChange, - onSelect, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - sandboxInstance.monaco.Uri, - codeActions, - tabs.code, - updateMarkers, - onMarkersChange, - ]); + const disposable = webLinter.onParse((uri, model) => { + onEsASTChange(model.storedAST); + onScopeChange(model.storedScope as Record | undefined); + onTsASTChange(model.storedTsAST); + }); + return () => disposable(); + }, [webLinter, onEsASTChange, onScopeChange, onTsASTChange]); useEffect(() => { // configure the JSON language support with schemas and schema associations - sandboxInstance.monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ validate: true, enableSchemaRequest: false, allowComments: true, schemas: [ { - uri: sandboxInstance.monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema - fileMatch: [tabs.eslintrc.uri.toString()], // associate with our model + uri: monaco.Uri.file('eslint-schema.json').toString(), // id of the first schema + fileMatch: ['/.eslintrc'], // associate with our model schema: getEslintJsonSchema(webLinter), }, { - uri: sandboxInstance.monaco.Uri.file('ts-schema.json').toString(), // id of the first schema - fileMatch: [tabs.tsconfig.uri.toString()], // associate with our model + uri: monaco.Uri.file('ts-schema.json').toString(), // id of the first schema + fileMatch: ['/tsconfig.json'], // associate with our model schema: getTypescriptJsonSchema(), }, ], }); + }, [monaco, webLinter]); - const subscriptions = [ - sandboxInstance.monaco.languages.registerCodeActionProvider( - 'typescript', - createProvideCodeActions(codeActions), - ), - sandboxInstance.editor.onDidPaste(() => { - if (tabs.eslintrc.isAttachedToEditor()) { - const value = tabs.eslintrc.getValue(); - const newValue = tryParseEslintModule(value); - if (newValue !== value) { - tabs.eslintrc.setValue(newValue); + useEffect(() => { + const disposable = monaco.languages.registerCodeActionProvider( + 'typescript', + createProvideCodeActions(codeActions), + ); + return () => disposable.dispose(); + }, [codeActions, monaco]); + + useEffect(() => { + const disposable = editor.onDidPaste(() => { + if (tabs.eslintrc.isAttachedToEditor()) { + const value = tabs.eslintrc.getValue(); + const newValue = tryParseEslintModule(value); + if (newValue !== value) { + tabs.eslintrc.setValue(newValue); + } + } + }); + return () => disposable.dispose(); + }, [editor, tabs.eslintrc]); + + useEffect(() => { + const disposable = editor.onDidChangeCursorPosition( + debounce(e => { + if (tabs.code.isAttachedToEditor()) { + const position = tabs.code.getOffsetAt(e.position); + console.info('[Editor] updating cursor', position); + onSelect(position); + } + }, 150), + ); + return () => disposable.dispose(); + }, [onSelect, editor, tabs.code]); + + useEffect(() => { + const disposable = editor.addAction({ + id: 'fix-eslint-problems', + label: 'Fix eslint problems', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + contextMenuGroupId: 'snippets', + contextMenuOrder: 1.5, + run(editor) { + const editorModel = editor.getModel(); + if (editorModel) { + const fixed = webLinter.triggerFix(editor.getValue()); + if (fixed?.fixed) { + applyEdit(editorModel, editor, { + range: editorModel.getFullModelRange(), + text: fixed.output, + }); } } + }, + }); + return () => disposable.dispose(); + }, [editor, monaco, webLinter]); + + useEffect(() => { + const closable = [ + system.watchFile('/tsconfig.json', filename => { + onChange({ tsconfig: system.readFile(filename) }); }), - sandboxInstance.editor.onDidChangeCursorPosition( - debounce(() => { - if (tabs.code.isAttachedToEditor()) { - const position = sandboxInstance.editor.getPosition(); - if (position) { - console.info('[Editor] updating cursor', position); - onSelect(tabs.code.getOffsetAt(position)); - } - } - }, 150), - ), - sandboxInstance.editor.addAction({ - id: 'fix-eslint-problems', - label: 'Fix eslint problems', - keybindings: [ - sandboxInstance.monaco.KeyMod.CtrlCmd | - sandboxInstance.monaco.KeyCode.KeyS, - ], - contextMenuGroupId: 'snippets', - contextMenuOrder: 1.5, - run(editor) { - const editorModel = editor.getModel(); - if (editorModel) { - const fixed = webLinter.fix( - editor.getValue(), - editorModel.uri.path, - ); - if (fixed.fixed) { - editorModel.pushEditOperations( - null, - [ - { - range: editorModel.getFullModelRange(), - text: fixed.output, - }, - ], - () => null, - ); - } - } - }, + system.watchFile('/.eslintrc', filename => { + onChange({ eslintrc: system.readFile(filename) }); }), - tabs.eslintrc.onDidChangeContent( - debounce(() => { - onChange({ eslintrc: tabs.eslintrc.getValue() }); - }, 500), - ), - tabs.tsconfig.onDidChangeContent( - debounce(() => { - onChange({ tsconfig: tabs.tsconfig.getValue() }); - }, 500), - ), - tabs.code.onDidChangeContent( - debounce(() => { - onChange({ code: tabs.code.getValue() }); - }, 500), - ), - // may not be defined in ts < 4.0.5 - sandboxInstance.monaco.editor.onDidChangeMarkers?.(() => { - updateMarkers(); + system.watchFile('/input.*', filename => { + onChange({ code: system.readFile(filename) }); }), ]; - return (): void => { - for (const subscription of subscriptions) { - if (subscription) { - subscription.dispose(); - } - } + return () => { + closable.forEach(c => c.close()); }; - }, [ - codeActions, - onChange, - onSelect, - sandboxInstance.editor, - sandboxInstance.monaco.editor, - sandboxInstance.monaco.languages.json.jsonDefaults, - tabs.code, - tabs.eslintrc, - tabs.tsconfig, - updateMarkers, - webLinter.rulesMap, - ]); + }, [system, onChange]); + + useEffect(() => { + const disposable = editor.onDidChangeModelContent(() => { + const model = editor.getModel(); + if (model) { + system.writeFile(model.uri.path, model.getValue()); + } + }); + return () => disposable.dispose(); + }, [editor, system]); + + useEffect(() => { + const disposable = monaco.editor.onDidChangeMarkers(() => { + updateMarkers(); + }); + return () => disposable.dispose(); + }, [monaco.editor, updateMarkers]); const resize = useMemo(() => { - return debounce(() => sandboxInstance.editor.layout(), 1); - }, [sandboxInstance]); + return debounce(() => editor.layout(), 1); + }, [editor]); - const container = - sandboxInstance.editor.getContainerDomNode?.() ?? - sandboxInstance.editor.getDomNode(); + const container = editor.getContainerDomNode?.() ?? editor.getDomNode(); useResizeObserver(container, () => { resize(); }); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - code !== tabs.code.getValue() - ) { - tabs.code.applyEdits([ - { - range: tabs.code.getFullModelRange(), - text: code, - }, - ]); + if (!editor.hasTextFocus() && code !== tabs.code.getValue()) { + applyEdit(tabs.code, editor, { + range: tabs.code.getFullModelRange(), + text: code, + }); } - }, [sandboxInstance, code, tabs.code]); + }, [code, editor, tabs.code]); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - tsconfig !== tabs.tsconfig.getValue() - ) { - tabs.tsconfig.applyEdits([ - { - range: tabs.tsconfig.getFullModelRange(), - text: tsconfig, - }, - ]); + if (!editor.hasTextFocus() && tsconfig !== tabs.tsconfig.getValue()) { + applyEdit(tabs.tsconfig, editor, { + range: tabs.tsconfig.getFullModelRange(), + text: tsconfig, + }); } - }, [sandboxInstance, tabs.tsconfig, tsconfig]); + }, [editor, tabs.tsconfig, tsconfig]); useEffect(() => { - if ( - !sandboxInstance.editor.hasTextFocus() && - eslintrc !== tabs.eslintrc.getValue() - ) { - tabs.eslintrc.applyEdits([ - { - range: tabs.eslintrc.getFullModelRange(), - text: eslintrc, - }, - ]); + if (!editor.hasTextFocus() && eslintrc !== tabs.eslintrc.getValue()) { + applyEdit(tabs.eslintrc, editor, { + range: tabs.eslintrc.getFullModelRange(), + text: eslintrc, + }); } - }, [sandboxInstance, eslintrc, tabs.eslintrc]); + }, [eslintrc, editor, tabs.eslintrc]); useEffect(() => { - sandboxInstance.monaco.editor.setTheme( - colorMode === 'dark' ? 'vs-dark' : 'vs-light', - ); - }, [colorMode, sandboxInstance]); + monaco.editor.setTheme(colorMode === 'dark' ? 'vs-dark' : 'vs-light'); + }, [colorMode, monaco]); useEffect(() => { setDecorations(prevDecorations => @@ -362,7 +307,7 @@ export const LoadedEditor: React.FC = ({ selectedRange && showAST ? [ { - range: sandboxInstance.monaco.Range.fromPositions( + range: monaco.Range.fromPositions( tabs.code.getPositionAt(selectedRange[0]), tabs.code.getPositionAt(selectedRange[1]), ), @@ -375,7 +320,11 @@ export const LoadedEditor: React.FC = ({ : [], ), ); - }, [selectedRange, sandboxInstance, showAST, tabs.code]); + }, [selectedRange, monaco, showAST, tabs.code]); + + useEffect(() => { + webLinter.triggerLint(tabs.code.uri.path); + }, [webLinter, fileType, sourceType, tabs.code]); return null; }; diff --git a/packages/website/src/components/editor/loadSandbox.ts b/packages/website/src/components/editor/loadSandbox.ts index 51b8b64295a0..f7798a4093eb 100644 --- a/packages/website/src/components/editor/loadSandbox.ts +++ b/packages/website/src/components/editor/loadSandbox.ts @@ -1,7 +1,7 @@ import type MonacoEditor from 'monaco-editor'; import type * as SandboxFactory from '../../vendor/sandbox'; -import type { LintUtils } from '../linter/WebLinter'; +import type { WebLinterModule } from '../linter/types'; type Monaco = typeof MonacoEditor; type Sandbox = typeof SandboxFactory; @@ -9,7 +9,7 @@ type Sandbox = typeof SandboxFactory; export interface SandboxModel { main: Monaco; sandboxFactory: Sandbox; - lintUtils: LintUtils; + lintUtils: WebLinterModule; } function loadSandbox(tsVersion: string): Promise { @@ -32,7 +32,7 @@ function loadSandbox(tsVersion: string): Promise { }); // Grab a copy of monaco, TypeScript and the sandbox - window.require<[Monaco, Sandbox, LintUtils]>( + window.require<[Monaco, Sandbox, WebLinterModule]>( ['vs/editor/editor.main', 'sandbox/index', 'linter/index'], (main, sandboxFactory, lintUtils) => { resolve({ main, sandboxFactory, lintUtils }); diff --git a/packages/website/src/components/editor/useSandboxServices.ts b/packages/website/src/components/editor/useSandboxServices.ts index 77ca6b82b3c8..21a7f51ea471 100644 --- a/packages/website/src/components/editor/useSandboxServices.ts +++ b/packages/website/src/components/editor/useSandboxServices.ts @@ -4,14 +4,15 @@ import { useEffect, useState } from 'react'; import type { createTypeScriptSandbox } from '../../vendor/sandbox'; import { createCompilerOptions } from '../lib/createCompilerOptions'; -import { WebLinter } from '../linter/WebLinter'; +import { createFileSystem } from '../linter/bridge'; +import { type CreateLinter, createLinter } from '../linter/createLinter'; +import type { PlaygroundSystem } from '../linter/types'; import type { RuleDetails } from '../types'; import { editorEmbedId } from './EditorEmbed'; import { sandboxSingleton } from './loadSandbox'; import type { CommonEditorProps } from './types'; export interface SandboxServicesProps { - readonly jsx?: boolean; readonly onLoaded: ( ruleDetails: RuleDetails[], tsVersions: readonly string[], @@ -23,7 +24,8 @@ export type SandboxInstance = ReturnType; export interface SandboxServices { sandboxInstance: SandboxInstance; - webLinter: WebLinter; + system: PlaygroundSystem; + webLinter: CreateLinter; } export const useSandboxServices = ( @@ -67,22 +69,27 @@ export const useSandboxServices = ( colorMode === 'dark' ? 'vs-dark' : 'vs-light', ); - const libEntries = new Map(); + const system = createFileSystem(props, sandboxInstance.tsvfs); + const worker = await sandboxInstance.getWorkerProcess(); if (worker.getLibFiles) { const libs = await worker.getLibFiles(); for (const [key, value] of Object.entries(libs)) { - libEntries.set('/' + key, value); + system.writeFile('/' + key, value); } } - const system = sandboxInstance.tsvfs.createSystem(libEntries); + window.system = system; window.esquery = lintUtils.esquery; - const webLinter = new WebLinter(system, compilerOptions, lintUtils); + const webLinter = createLinter( + system, + lintUtils, + sandboxInstance.tsvfs, + ); onLoaded( - Array.from(webLinter.rulesMap.values()), + Array.from(webLinter.rules.values()), Array.from( new Set([...sandboxInstance.supportedVersions, window.ts.version]), ) @@ -91,8 +98,9 @@ export const useSandboxServices = ( ); setServices({ - sandboxInstance, + system, webLinter, + sandboxInstance, }); }) .catch(setServices); diff --git a/packages/website/src/components/lib/createEventsBinder.ts b/packages/website/src/components/lib/createEventsBinder.ts new file mode 100644 index 000000000000..6b5bfaecbee1 --- /dev/null +++ b/packages/website/src/components/lib/createEventsBinder.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createEventsBinder void>(): { + trigger: (...args: Parameters) => void; + register: (cb: T) => () => void; +} { + const events = new Set(); + + return { + trigger(...args: Parameters): void { + events.forEach(cb => cb(...args)); + }, + register(cb: T): () => void { + events.add(cb); + return (): void => { + events.delete(cb); + }; + }, + }; +} diff --git a/packages/website/src/components/lib/jsonSchema.ts b/packages/website/src/components/lib/jsonSchema.ts index 0b49632631be..b43b6ad72cb9 100644 --- a/packages/website/src/components/lib/jsonSchema.ts +++ b/packages/website/src/components/lib/jsonSchema.ts @@ -1,16 +1,16 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; import type * as ts from 'typescript'; -import type { WebLinter } from '../linter/WebLinter'; +import type { CreateLinter } from '../linter/createLinter'; /** * Get the JSON schema for the eslint config * Currently we only support the rules and extends */ -export function getEslintJsonSchema(linter: WebLinter): JSONSchema4 { +export function getEslintJsonSchema(linter: CreateLinter): JSONSchema4 { const properties: Record = {}; - for (const [, item] of linter.rulesMap) { + for (const [, item] of linter.rules) { properties[item.name] = { description: `${item.description}\n ${item.url}`, title: item.name.startsWith('@typescript') ? 'Rules' : 'Core rules', diff --git a/packages/website/src/components/linter/CompilerHost.ts b/packages/website/src/components/linter/CompilerHost.ts deleted file mode 100644 index 22fd9fa83c67..000000000000 --- a/packages/website/src/components/linter/CompilerHost.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CompilerHost, SourceFile, System } from 'typescript'; - -import type { LintUtils } from './WebLinter'; - -/** - * Creates an in-memory CompilerHost -which is essentially an extra wrapper to System - * which works with TypeScript objects - returns both a compiler host, and a way to add new SourceFile - * instances to the in-memory file system. - * - * based on typescript-vfs - * @see https://github.com/microsoft/TypeScript-Website/blob/d2613c0e57ae1be2f3a76e94b006819a1fc73d5e/packages/typescript-vfs/src/index.ts#L480 - */ -export function createVirtualCompilerHost( - sys: System, - lintUtils: LintUtils, -): CompilerHost { - return { - ...sys, - getCanonicalFileName: (fileName: string) => fileName, - getDefaultLibFileName: options => - '/' + window.ts.getDefaultLibFileName(options), - getNewLine: () => sys.newLine, - getSourceFile(fileName, languageVersionOrOptions): SourceFile | undefined { - if (this.fileExists(fileName)) { - const file = this.readFile(fileName) ?? ''; - return window.ts.createSourceFile( - fileName, - file, - languageVersionOrOptions, - true, - lintUtils.getScriptKind(fileName, false), - ); - } - return undefined; - }, - useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames, - }; -} diff --git a/packages/website/src/components/linter/WebLinter.ts b/packages/website/src/components/linter/WebLinter.ts deleted file mode 100644 index f789a7417173..000000000000 --- a/packages/website/src/components/linter/WebLinter.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { analyze } from '@typescript-eslint/scope-manager'; -import type { ParserOptions } from '@typescript-eslint/types'; -import type { - astConverter, - getScriptKind, -} from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; -import type esquery from 'esquery'; -import type * as ts from 'typescript'; - -import type { EslintRC, RuleDetails } from '../types'; -import { createVirtualCompilerHost } from './CompilerHost'; -import { eslintConfig, PARSER_NAME, parseSettings } from './config'; - -export interface LintUtils { - createLinter: () => TSESLint.Linter; - analyze: typeof analyze; - visitorKeys: TSESLint.SourceCode.VisitorKeys; - astConverter: typeof astConverter; - getScriptKind: typeof getScriptKind; - esquery: typeof esquery; - configs: Record; -} - -export class WebLinter { - private readonly host: ts.CompilerHost; - - public storedAST?: TSESTree.Program; - public storedTsAST?: ts.SourceFile; - public storedScope?: Record; - - private compilerOptions: ts.CompilerOptions; - private eslintConfig = eslintConfig; - - private linter: TSESLint.Linter; - private lintUtils: LintUtils; - - public readonly rulesMap = new Map(); - public readonly configs: Record = {}; - - constructor( - system: ts.System, - compilerOptions: ts.CompilerOptions, - lintUtils: LintUtils, - ) { - this.compilerOptions = compilerOptions; - this.lintUtils = lintUtils; - this.linter = lintUtils.createLinter(); - - this.host = createVirtualCompilerHost(system, lintUtils); - - this.linter.defineParser(PARSER_NAME, { - parseForESLint: (text, options?: ParserOptions) => { - return this.eslintParse(text, options); - }, - }); - - this.configs = lintUtils.configs; - - this.linter.getRules().forEach((item, name) => { - this.rulesMap.set(name, { - name: name, - description: item.meta?.docs?.description, - url: item.meta?.docs?.url, - }); - }); - } - - lint(code: string, filename: string): TSESLint.Linter.LintMessage[] { - return this.linter.verify(code, this.eslintConfig, { - filename: filename, - }); - } - - fix(code: string, filename: string): TSESLint.Linter.FixReport { - return this.linter.verifyAndFix(code, this.eslintConfig, { - filename: filename, - fix: true, - }); - } - - updateEslintConfig(config: EslintRC): void { - const resolvedConfig = this.resolveEslintConfig(config); - this.eslintConfig.rules = resolvedConfig.rules; - } - - updateParserOptions(sourceType?: TSESLint.SourceType): void { - this.eslintConfig.parserOptions ??= {}; - this.eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; - } - - updateCompilerOptions(options: ts.CompilerOptions = {}): void { - this.compilerOptions = options; - } - - eslintParse( - code: string, - eslintOptions: ParserOptions = {}, - ): TSESLint.Linter.ESLintParseResult { - const fileName = eslintOptions.filePath ?? '/input.ts'; - - this.storedAST = undefined; - this.storedTsAST = undefined; - this.storedScope = undefined; - - this.host.writeFile(fileName, code || '\n', false); - - const program = window.ts.createProgram({ - rootNames: [fileName], - options: this.compilerOptions, - host: this.host, - }); - const tsAst = program.getSourceFile(fileName)!; - const checker = program.getTypeChecker(); - - const { estree: ast, astMaps } = this.lintUtils.astConverter( - tsAst, - { ...parseSettings, code, codeFullText: code }, - true, - ); - - const scopeManager = this.lintUtils.analyze(ast, { - globalReturn: eslintOptions.ecmaFeatures?.globalReturn ?? false, - sourceType: eslintOptions.sourceType ?? 'script', - }); - - this.storedAST = ast; - this.storedTsAST = tsAst; - this.storedScope = scopeManager as unknown as Record; - - return { - ast, - services: { - program, - esTreeNodeToTSNodeMap: astMaps.esTreeNodeToTSNodeMap, - tsNodeToESTreeNodeMap: astMaps.tsNodeToESTreeNodeMap, - getSymbolAtLocation: node => - checker.getSymbolAtLocation(astMaps.esTreeNodeToTSNodeMap.get(node)), - getTypeAtLocation: node => - checker.getTypeAtLocation(astMaps.esTreeNodeToTSNodeMap.get(node)), - }, - scopeManager, - visitorKeys: this.lintUtils.visitorKeys, - }; - } - - private resolveEslintConfig( - cfg: Partial, - ): TSESLint.Linter.Config { - const config = { - rules: {}, - overrides: [], - }; - if (cfg.extends) { - const cfgExtends = Array.isArray(cfg.extends) - ? cfg.extends - : [cfg.extends]; - for (const extendsName of cfgExtends) { - if (typeof extendsName === 'string' && extendsName in this.configs) { - const resolved = this.resolveEslintConfig(this.configs[extendsName]); - if (resolved.rules) { - Object.assign(config.rules, resolved.rules); - } - } - } - } - if (cfg.rules) { - Object.assign(config.rules, cfg.rules); - } - return config; - } -} diff --git a/packages/website/src/components/linter/bridge.ts b/packages/website/src/components/linter/bridge.ts new file mode 100644 index 000000000000..b0614fc4329d --- /dev/null +++ b/packages/website/src/components/linter/bridge.ts @@ -0,0 +1,79 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type * as ts from 'typescript'; + +import { debounce } from '../lib/debounce'; +import type { ConfigModel } from '../types'; +import type { PlaygroundSystem } from './types'; + +export function createFileSystem( + config: Pick, + vfs: typeof tsvfs, +): PlaygroundSystem { + const files = new Map(); + files.set(`/.eslintrc`, config.eslintrc); + files.set(`/tsconfig.json`, config.tsconfig); + files.set(`/input${config.fileType}`, config.code); + + const fileWatcherCallbacks = new Map>(); + + const system = vfs.createSystem(files) as PlaygroundSystem; + + system.watchFile = ( + path, + callback, + pollingInterval = 500, + ): ts.FileWatcher => { + const cb = pollingInterval ? debounce(callback, pollingInterval) : callback; + + const escapedPath = path.replace(/\./g, '\\.').replace(/\*/g, '[^/]+'); + const expPath = new RegExp(`^${escapedPath}$`, ''); + + let handle = fileWatcherCallbacks.get(expPath); + if (!handle) { + handle = new Set(); + fileWatcherCallbacks.set(expPath, handle); + } + handle.add(cb); + + return { + close: (): void => { + fileWatcherCallbacks.get(expPath)?.delete(cb); + }, + }; + }; + + const triggerCallbacks = ( + path: string, + type: ts.FileWatcherEventKind, + ): void => { + fileWatcherCallbacks.forEach((callbacks, key) => { + if (key.test(path)) { + callbacks.forEach(cb => cb(path, type)); + } + }); + }; + + system.deleteFile = (fileName): void => { + files.delete(fileName); + triggerCallbacks(fileName, 1); + }; + + system.writeFile = (fileName, contents): void => { + if (!contents) { + contents = ''; + } + const file = files.get(fileName); + if (file === contents) { + // do not trigger callbacks if the file has not changed + return; + } + files.set(fileName, contents); + triggerCallbacks(fileName, file ? 2 : 0); + }; + + system.removeFile = (fileName): void => { + files.delete(fileName); + }; + + return system; +} diff --git a/packages/website/src/components/linter/config.ts b/packages/website/src/components/linter/config.ts index 641adc873c1b..f728dc1a6355 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -3,7 +3,7 @@ import type { TSESLint } from '@typescript-eslint/utils'; export const PARSER_NAME = '@typescript-eslint/parser'; -export const parseSettings: ParseSettings = { +export const defaultParseSettings: ParseSettings = { allowInvalidAST: false, code: '', codeFullText: '', @@ -30,11 +30,11 @@ export const parseSettings: ParseSettings = { tsconfigRootDir: '/', }; -export const eslintConfig: TSESLint.Linter.Config = { +export const defaultEslintConfig: TSESLint.Linter.Config = { parser: PARSER_NAME, parserOptions: { ecmaFeatures: { - jsx: false, + jsx: true, globalReturn: false, }, ecmaVersion: 'latest', diff --git a/packages/website/src/components/linter/createLinter.ts b/packages/website/src/components/linter/createLinter.ts new file mode 100644 index 000000000000..813fd10eaf24 --- /dev/null +++ b/packages/website/src/components/linter/createLinter.ts @@ -0,0 +1,152 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { createCompilerOptions } from '../lib/createCompilerOptions'; +import { createEventsBinder } from '../lib/createEventsBinder'; +import { parseESLintRC, parseTSConfig } from '../lib/parseConfig'; +import { defaultEslintConfig, PARSER_NAME } from './config'; +import { createParser } from './createParser'; +import type { + LinterOnLint, + LinterOnParse, + PlaygroundSystem, + WebLinterModule, +} from './types'; + +export interface CreateLinter { + rules: Map; + configs: string[]; + triggerFix(filename: string): TSESLint.Linter.FixReport | undefined; + triggerLint(filename: string): void; + onLint(cb: LinterOnLint): () => void; + onParse(cb: LinterOnParse): () => void; + updateParserOptions(sourceType?: TSESLint.SourceType): void; +} + +export function createLinter( + system: PlaygroundSystem, + webLinterModule: WebLinterModule, + vfs: typeof tsvfs, +): CreateLinter { + const rules: CreateLinter['rules'] = new Map(); + const configs = new Map(Object.entries(webLinterModule.configs)); + let compilerOptions: ts.CompilerOptions = {}; + const eslintConfig: TSESLint.Linter.Config = { ...defaultEslintConfig }; + + const onLint = createEventsBinder(); + const onParse = createEventsBinder(); + + const linter = webLinterModule.createLinter(); + + const parser = createParser( + system, + compilerOptions, + (filename, model): void => { + onParse.trigger(filename, model); + }, + webLinterModule, + vfs, + ); + + linter.defineParser(PARSER_NAME, parser); + + linter.getRules().forEach((item, name) => { + rules.set(name, { + name: name, + description: item.meta?.docs?.description, + url: item.meta?.docs?.url, + }); + }); + + const triggerLint = (filename: string): void => { + console.info('[Editor] linting triggered for file', filename); + const code = system.readFile(filename) ?? '\n'; + if (code != null) { + const messages = linter.verify(code, eslintConfig, filename); + onLint.trigger(filename, messages); + } + }; + + const triggerFix = ( + filename: string, + ): TSESLint.Linter.FixReport | undefined => { + const code = system.readFile(filename); + if (code) { + return linter.verifyAndFix(code, eslintConfig, { + filename: filename, + fix: true, + }); + } + return undefined; + }; + + const updateParserOptions = (sourceType?: TSESLint.SourceType): void => { + eslintConfig.parserOptions ??= {}; + eslintConfig.parserOptions.sourceType = sourceType ?? 'module'; + }; + + const resolveEslintConfig = ( + cfg: Partial, + ): TSESLint.Linter.Config => { + const config = { rules: {} }; + if (cfg.extends) { + const cfgExtends = Array.isArray(cfg.extends) + ? cfg.extends + : [cfg.extends]; + for (const extendsName of cfgExtends) { + const maybeConfig = configs.get(extendsName); + if (maybeConfig) { + const resolved = resolveEslintConfig(maybeConfig); + if (resolved.rules) { + Object.assign(config.rules, resolved.rules); + } + } + } + } + if (cfg.rules) { + Object.assign(config.rules, cfg.rules); + } + return config; + }; + + const applyEslintConfig = (fileName: string): void => { + try { + const file = system.readFile(fileName) ?? '{}'; + const parsed = resolveEslintConfig(parseESLintRC(file)); + eslintConfig.rules = parsed.rules; + console.log('[Editor] Updating', fileName, eslintConfig); + } catch (e) { + console.error(e); + } + }; + + const applyTSConfig = (fileName: string): void => { + try { + const file = system.readFile(fileName) ?? '{}'; + const parsed = parseTSConfig(file).compilerOptions; + compilerOptions = createCompilerOptions(parsed); + console.log('[Editor] Updating', fileName, compilerOptions); + parser.updateConfig(compilerOptions); + } catch (e) { + console.error(e); + } + }; + + system.watchFile('/input.*', triggerLint); + system.watchFile('/.eslintrc', applyEslintConfig); + system.watchFile('/tsconfig.json', applyTSConfig); + + applyEslintConfig('/.eslintrc'); + applyTSConfig('/tsconfig.json'); + + return { + rules, + configs: Array.from(configs.keys()), + triggerFix, + triggerLint, + updateParserOptions, + onParse: onParse.register, + onLint: onLint.register, + }; +} diff --git a/packages/website/src/components/linter/createParser.ts b/packages/website/src/components/linter/createParser.ts new file mode 100644 index 000000000000..1c0d9258a8f2 --- /dev/null +++ b/packages/website/src/components/linter/createParser.ts @@ -0,0 +1,108 @@ +import type * as tsvfs from '@site/src/vendor/typescript-vfs'; +import type { ParserOptions } from '@typescript-eslint/types'; +import type { TSESLint } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { defaultParseSettings } from './config'; +import type { + ParseSettings, + PlaygroundSystem, + UpdateModel, + WebLinterModule, +} from './types'; + +export function createParser( + system: PlaygroundSystem, + compilerOptions: ts.CompilerOptions, + onUpdate: (filename: string, model: UpdateModel) => void, + utils: WebLinterModule, + vfs: typeof tsvfs, +): TSESLint.Linter.ParserModule & { + updateConfig: (compilerOptions: ts.CompilerOptions) => void; +} { + const registeredFiles = new Set(); + + const createEnv = ( + compilerOptions: ts.CompilerOptions, + ): tsvfs.VirtualTypeScriptEnvironment => { + return vfs.createVirtualTypeScriptEnvironment( + system, + Array.from(registeredFiles), + window.ts, + compilerOptions, + ); + }; + + let compilerHost = createEnv(compilerOptions); + + return { + updateConfig(compilerOptions): void { + compilerHost = createEnv(compilerOptions); + }, + parseForESLint: ( + text: string, + options: ParserOptions = {}, + ): TSESLint.Linter.ESLintParseResult => { + const filePath = options.filePath ?? '/input.ts'; + + // if text is empty use empty line to avoid error + const code = text || '\n'; + + if (registeredFiles.has(filePath)) { + compilerHost.updateFile(filePath, code); + } else { + registeredFiles.add(filePath); + compilerHost.createFile(filePath, code); + } + + const parseSettings: ParseSettings = { + ...defaultParseSettings, + code: code, + codeFullText: code, + filePath: filePath, + }; + + const program = compilerHost.languageService.getProgram(); + if (!program) { + throw new Error('Failed to get program'); + } + + const tsAst = program.getSourceFile(filePath)!; + + const converted = utils.astConverter(tsAst, parseSettings, true); + + const scopeManager = utils.analyze(converted.estree, { + globalReturn: options.ecmaFeatures?.globalReturn ?? false, + sourceType: options.sourceType ?? 'module', + }); + + const checker = program.getTypeChecker(); + + onUpdate(filePath, { + storedAST: converted.estree, + storedTsAST: tsAst, + storedScope: scopeManager, + typeChecker: checker, + }); + + return { + ast: converted.estree, + services: { + program, + esTreeNodeToTSNodeMap: converted.astMaps.esTreeNodeToTSNodeMap, + tsNodeToESTreeNodeMap: converted.astMaps.tsNodeToESTreeNodeMap, + getSymbolAtLocation: node => + checker.getSymbolAtLocation( + converted.astMaps.esTreeNodeToTSNodeMap.get(node), + ), + getTypeAtLocation: node => + checker.getTypeAtLocation( + converted.astMaps.esTreeNodeToTSNodeMap.get(node), + ), + }, + scopeManager, + visitorKeys: utils.visitorKeys, + }; + }, + }; +} diff --git a/packages/website/src/components/linter/types.ts b/packages/website/src/components/linter/types.ts new file mode 100644 index 000000000000..3623eb649ff7 --- /dev/null +++ b/packages/website/src/components/linter/types.ts @@ -0,0 +1,35 @@ +import type { analyze, ScopeManager } from '@typescript-eslint/scope-manager'; +import type { astConverter } from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type esquery from 'esquery'; +import type * as ts from 'typescript'; + +export type { ParseSettings } from '@typescript-eslint/typescript-estree/use-at-your-own-risk'; + +export interface UpdateModel { + storedAST?: TSESTree.Program; + storedTsAST?: ts.SourceFile; + storedScope?: ScopeManager; + typeChecker?: ts.TypeChecker; +} + +export interface WebLinterModule { + createLinter: () => TSESLint.Linter; + analyze: typeof analyze; + visitorKeys: TSESLint.SourceCode.VisitorKeys; + astConverter: typeof astConverter; + esquery: typeof esquery; + configs: Record; +} + +export type PlaygroundSystem = ts.System & + Required> & { + removeFile: (fileName: string) => void; + }; + +export type LinterOnLint = ( + fileName: string, + messages: TSESLint.Linter.LintMessage[], +) => void; + +export type LinterOnParse = (fileName: string, model: UpdateModel) => void; diff --git a/packages/website/src/globals.d.ts b/packages/website/src/globals.d.ts index 898d3649bee2..0f4c2463b060 100644 --- a/packages/website/src/globals.d.ts +++ b/packages/website/src/globals.d.ts @@ -18,5 +18,6 @@ declare global { ts: typeof ts; require: WindowRequire; esquery: typeof esquery; + system: unknown; } }