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: refactor syntax checker #431

Merged
merged 2 commits into from
Oct 6, 2024
Merged
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
138 changes: 32 additions & 106 deletions web/src/components/features/workspace/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,20 @@ import MonacoEditor, { type Monaco } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'

import { createVimModeAdapter, type StatusBarAdapter, type VimModeKeymap } from '~/plugins/vim/editor'
import { Analyzer } from '~/services/analyzer'
import { type MonacoSettings, TargetType } from '~/services/config'
import {
connect,
newMarkerAction,
newMonacoParamsChangeDispatcher,
runFileDispatcher,
type StateDispatch,
type State,
} from '~/store'
import { type WorkspaceState, dispatchFormatFile, dispatchResetWorkspace, dispatchUpdateFile } from '~/store/workspace'
import { connect, newMonacoParamsChangeDispatcher, type StateDispatch, type State } from '~/store'
import { type WorkspaceState, dispatchUpdateFile } from '~/store/workspace'
import type { VimState } from '~/store/vim/state'
import { spawnLanguageWorker } from '~/workers/language'
import { getTimeNowUsageMarkers, asyncDebounce, debounce } from './utils/utils'
import { attachCustomCommands } from './utils/commands'
import { GoSyntaxChecker } from './syntaxcheck'
import { debounce } from './utils/utils'
import { attachCustomCommands, registerEditorActions } from './utils/commands'
import { stateToOptions } from './utils/props'
import { configureMonacoLoader } from './utils/loader'
import { DocumentMetadataCache, registerGoLanguageProviders } from './autocomplete'
import { languageFromFilename, registerExtraLanguages } from './grammar'
import classes from './CodeEditor.module.css'

const ANALYZE_DEBOUNCE_TIME = 500

// ask monaco-editor/react to use our own Monaco instance.
configureMonacoLoader()

Expand All @@ -47,19 +38,15 @@ export interface Props extends CodeEditorState {
}

class CodeEditorView extends React.Component<Props> {
private analyzer?: Analyzer
private editorInstance?: monaco.editor.IStandaloneCodeEditor
private syntaxChecker?: GoSyntaxChecker
private editor?: monaco.editor.IStandaloneCodeEditor
private vimAdapter?: VimModeKeymap
private vimCommandAdapter?: StatusBarAdapter
private monaco?: Monaco
private saveTimeoutId?: ReturnType<typeof setTimeout>
private readonly disposables: monaco.IDisposable[] = []
private readonly metadataCache = new DocumentMetadataCache()

private readonly debouncedAnalyzeFunc = asyncDebounce(async (fileName: string, code: string) => {
return await this.doAnalyze(fileName, code)
}, ANALYZE_DEBOUNCE_TIME)

private addDisposer(...disposers: monaco.IDisposable[]) {
this.disposables.push(...disposers)
}
Expand All @@ -72,25 +59,25 @@ class CodeEditorView extends React.Component<Props> {
)
}, 1000)

editorDidMount(editorInstance: monaco.editor.IStandaloneCodeEditor, monacoInstance: Monaco) {
editorDidMount(editor: monaco.editor.IStandaloneCodeEditor, monacoInstance: Monaco) {
this.syntaxChecker = new GoSyntaxChecker(this.props.dispatch)
const [langWorker, workerDisposer] = spawnLanguageWorker()

this.addDisposer(registerExtraLanguages())
this.addDisposer(workerDisposer)
this.addDisposer(workerDisposer, this.syntaxChecker, registerExtraLanguages())
this.addDisposer(...registerGoLanguageProviders(this.props.dispatch, this.metadataCache, langWorker))
this.editorInstance = editorInstance
this.editor = editor
this.monaco = monacoInstance

editorInstance.onKeyDown((e) => this.onKeyDown(e))
const [vimAdapter, statusAdapter] = createVimModeAdapter(this.props.dispatch, editorInstance)
editor.onKeyDown((e) => this.onKeyDown(e))
const [vimAdapter, statusAdapter] = createVimModeAdapter(this.props.dispatch, editor)
this.vimAdapter = vimAdapter
this.vimCommandAdapter = statusAdapter

// Font should be set only once during boot as when font size changes
// by zoom and editor config object is updated - this cause infinite
// font change calls with random values.
if (this.props.options.fontSize) {
editorInstance.updateOptions({
editor.updateOptions({
fontSize: this.props.options.fontSize,
})
}
Expand All @@ -100,58 +87,28 @@ class CodeEditorView extends React.Component<Props> {
this.vimAdapter.attach()
}

if (Analyzer.supported()) {
this.analyzer = new Analyzer()
} else {
console.info('Analyzer requires WebAssembly support')
}

const actions = [
{
id: 'clear',
label: 'Reset contents',
contextMenuGroupId: 'navigation',
run: () => {
this.props.dispatch(dispatchResetWorkspace)
},
},
{
id: 'run-code',
label: 'Build And Run Code',
contextMenuGroupId: 'navigation',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
run: () => {
this.props.dispatch(runFileDispatcher)
},
},
{
id: 'format-code',
label: 'Format Code (goimports)',
contextMenuGroupId: 'navigation',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF],
run: () => {
this.props.dispatch(dispatchFormatFile())
},
},
]

// Persist font size on zoom
this.addDisposer(
editorInstance.onDidChangeConfiguration((e) => {
editor.onDidChangeConfiguration((e) => {
if (e.hasChanged(monaco.editor.EditorOption.fontSize)) {
const newFontSize = editorInstance.getOption(monaco.editor.EditorOption.fontSize)
const newFontSize = editor.getOption(monaco.editor.EditorOption.fontSize)
this.persistFontSize(newFontSize)
}
}),
)

// Register custom actions
actions.forEach((action) => editorInstance.addAction(action))
attachCustomCommands(editorInstance)
editorInstance.focus()
registerEditorActions(editor, this.props.dispatch)
attachCustomCommands(editor)
editor.focus()

this.updateModelMarkers()
}

const { fileName, code } = this.props
void this.debouncedAnalyzeFunc(fileName, code)
private updateModelMarkers() {
this.syntaxChecker?.requestModelMarkers(this.editor?.getModel(), this.editor, {
isServerEnvironment: this.props.isServerEnvironment,
})
}

private isFileOrEnvironmentChanged(prevProps: Props) {
Expand Down Expand Up @@ -182,26 +139,25 @@ class CodeEditorView extends React.Component<Props> {

if (this.isFileOrEnvironmentChanged(prevProps)) {
// Update editor markers on file or environment changes
void this.debouncedAnalyzeFunc(this.props.fileName, this.props.code)
this.updateModelMarkers()
}

this.applyVimModeChanges(prevProps)
}

componentWillUnmount() {
this.disposables?.forEach((d) => d.dispose())
this.analyzer?.dispose()
this.vimAdapter?.dispose()
this.metadataCache.flush()

if (!this.editorInstance) {
if (!this.editor) {
return
}

// Shutdown instance to avoid dangling markers.
this.monaco?.editor.removeAllMarkers(this.editorInstance.getId())
this.monaco?.editor.removeAllMarkers(this.editor.getId())
this.monaco?.editor.getModels().forEach((m) => m.dispose())
this.editorInstance.dispose()
this.editor.dispose()
}

onChange(newValue: string | undefined, e: monaco.editor.IModelContentChangedEvent) {
Expand All @@ -210,9 +166,9 @@ class CodeEditorView extends React.Component<Props> {
return
}

const { fileName, code } = this.props
const { fileName } = this.props
this.metadataCache.handleUpdate(fileName, e)
void this.debouncedAnalyzeFunc(fileName, code)
this.updateModelMarkers()

// HACK: delay state updates to workaround cursor reset on completion.
//
Expand All @@ -231,36 +187,6 @@ class CodeEditorView extends React.Component<Props> {
}, 100)
}

private async doAnalyze(fileName: string, code: string) {
if (!fileName.endsWith('.go')) {
// Ignore non-go files
return
}

// Code analysis contains 2 steps that run on different conditions:
// 1. Run Go worker if it's available and check for errors
// 2. Add warnings to `time.Now` calls if code runs on server.
const promises = [
this.analyzer?.getMarkers(code) ?? null,
this.props.isServerEnvironment ? Promise.resolve(getTimeNowUsageMarkers(code, this.editorInstance!)) : null,
].filter((p) => !!p)

const results = await Promise.allSettled(promises)
const markers = results.flatMap((r) => {
// Can't do in beautiful way due of TS strict checks.
if (r.status === 'rejected') {
console.error(r.reason)
return []
}

return r.value ?? []
})

if (!this.editorInstance) return
this.monaco?.editor.setModelMarkers(this.editorInstance.getModel()!, this.editorInstance.getId(), markers)
this.props.dispatch(newMarkerAction(fileName, markers))
}

private onKeyDown(e: monaco.IKeyboardEvent) {
const { vimModeEnabled, vim } = this.props
if (!vimModeEnabled || !vim?.commandStarted) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type * as monaco from 'monaco-editor'
import type { ImportsContext } from '~/workers/language/types'
import { buildImportContext } from './parser/imports'

const stripSlash = (str: string) => (str[0] === '/' ? str.slice(1) : str)
import { stripSlash } from './utils'

/**
* Stores document metadata (such as symbols, imports) in cache.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const stripSlash = (str: string) => (str[0] === '/' ? str.slice(1) : str)
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const concatDisposables = (...items: monaco.IDisposable[]): monaco.IDisposable =
})

export const registerExtraLanguages = (): monaco.IDisposable => {
console.log('register')
if (!isLangRegistered(LanguageID.GoMod)) {
monaco.languages.register({
id: 'go.mod',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as monaco from 'monaco-editor'
import { newMarkerAction, type StateDispatch } from '~/store'
import { type AnalyzerWorker, spawnAnalyzerWorker } from '~/workers/analyzer'
import { LanguageID } from '../grammar'
import { getTimeNowUsageMarkers } from './time'
import { stripSlash } from '../autocomplete/utils'

type EditorInstance = monaco.editor.IStandaloneCodeEditor | null | undefined
type TimeoutId = ReturnType<typeof setTimeout>
export interface EnvironmentContext {
isServerEnvironment?: boolean
}

const debounceInterval = 500

export class GoSyntaxChecker implements monaco.IDisposable {
private readonly disposer: monaco.IDisposable
private readonly worker: AnalyzerWorker
private readonly timeoutId?: TimeoutId

constructor(private readonly dispatcher: StateDispatch) {
const [worker, disposer] = spawnAnalyzerWorker()
this.worker = worker
this.disposer = disposer
}

requestModelMarkers(
model: monaco.editor.ITextModel | null | undefined,
editor: EditorInstance,
ctx: EnvironmentContext,
) {
if (!editor || !model || model.getLanguageId() !== LanguageID.Go) {
return
}

this.cancelScheduledChecks()
setTimeout(() => {
void this.checkModel(model, editor.getId(), ctx)
}, debounceInterval)
}

cancelScheduledChecks() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
}

private async checkModel(model: monaco.editor.ITextModel, editorId: string, ctx: EnvironmentContext) {
// Code analysis contains 2 steps that run on different conditions:
// 1. Add warnings to `time.Now` calls if code runs on server.
// 2. Run Go worker if it's available and check for errors
const fileName = model.uri.fsPath
const markers: monaco.editor.IMarkerData[] = ctx.isServerEnvironment ? getTimeNowUsageMarkers(model) : []

try {
const response = await this.worker.checkSyntaxErrors({
fileName,
contents: model.getValue(),
})

if (response.fileName === fileName && response.markers) {
markers.push(...response.markers)
}
} catch (err) {
console.error('failed to perform syntax check', err)
}

monaco.editor.setModelMarkers(model, editorId, markers)
this.dispatcher(newMarkerAction(stripSlash(fileName), markers))
}

dispose() {
this.disposer.dispose()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './checker'
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as monaco from 'monaco-editor'
import environment from '~/environment'

const segmentLength = 'time.Now()'.length
const issueUrl = monaco.Uri.parse(`${environment.urls.github}/issues/104`)
const timeNowUsageWarning =
'Warning: `time.Now()` will always return fake time. ' +
'Change current environment to WebAssembly to use real date and time.'

/**
* Checks if passed source code contains `time.Now()` calls and returns
* a list of warning markers for those calls.
*
* Returns empty array if no calls found.
*/
export const getTimeNowUsageMarkers = (model: monaco.editor.ITextModel): monaco.editor.IMarkerData[] => {
const code = model.getValue()
const regex = /\W(time\.Now\(\))/gm
const matches: number[] = []
let match: RegExpExecArray | null
while ((match = regex.exec(code))) {
matches.push(match.index)
}

if (matches.length === 0) {
return []
}

return matches.map((index) => {
// Skip extra character or white-space before expression
const { lineNumber, column } = model.getPositionAt(index + 1)
return {
code: {
value: 'More information',
target: issueUrl,
},
severity: monaco.MarkerSeverity.Warning,
message: timeNowUsageWarning,
startLineNumber: lineNumber,
endLineNumber: lineNumber,
startColumn: column,
endColumn: column + segmentLength,
}
})
}
Loading