Skip to content

Commit

Permalink
feat: refactor syntax checker (#431)
Browse files Browse the repository at this point in the history
* feat: refactor analyzer logic

* fix: remove log
  • Loading branch information
x1unix authored Oct 6, 2024
1 parent 7875d91 commit 16a0bde
Show file tree
Hide file tree
Showing 16 changed files with 339 additions and 365 deletions.
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

0 comments on commit 16a0bde

Please sign in to comment.