Skip to content

Commit

Permalink
Merge branch 'main' into type-guards
Browse files Browse the repository at this point in the history
  • Loading branch information
WardBrian authored Jun 28, 2024
2 parents 9318bdc + 7c55ce0 commit 6d6536d
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 44 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,20 @@ jobs:
run: cd gui; yarn
- name: Test
run: cd gui; yarn test

frontend-build:
name: yarn build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
cache-dependency-path: gui/yarn.lock
- name: Install dependencies
run: cd gui; yarn
- name: Build
run: cd gui; yarn build
89 changes: 87 additions & 2 deletions gui/src/app/FileEditor/StanFileEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { AutoFixHigh, Cancel, Settings, } from "@mui/icons-material";
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react";
import StanCompileResultWindow from "./StanCompileResultWindow";
import useStanc from "../Stanc/useStanc";
import TextEditor, { ToolbarItem } from "./TextEditor";
import TextEditor, { CodeMarker, ToolbarItem } from "./TextEditor";
import compileStanProgram from '../compileStanProgram/compileStanProgram';
import { StancErrors } from '../Stanc/Types';

type Props = {
fileName: string
Expand Down Expand Up @@ -157,7 +158,7 @@ const StanFileEditor: FunctionComponent<Props> = ({ fileName, fileContent, onSav
}

return ret
}, [editedFileContent, fileContent, handleCompile, requestFormat, showLabelsOnButtons, validSyntax, compileStatus, compileMessage, readOnly])
}, [editedFileContent, fileContent, handleCompile, requestFormat, showLabelsOnButtons, validSyntax, compileStatus, compileMessage, readOnly, hasWarnings])

const isCompiling = compileStatus === 'compiling'

Expand All @@ -183,6 +184,7 @@ const StanFileEditor: FunctionComponent<Props> = ({ fileName, fileContent, onSav
onSetEditedText={setEditedFileContent}
readOnly={!isCompiling ? readOnly : true}
toolbarItems={toolbarItems}
codeMarkers={stancErrorsToCodeMarkers(stancErrors)}
/>
{
editedFileContent ? <StanCompileResultWindow
Expand Down Expand Up @@ -213,5 +215,88 @@ const stringChecksum = (str: string) => {
return hash;
}

const stancErrorsToCodeMarkers = (stancErrors: StancErrors) => {
const codeMarkers: CodeMarker[] = []

for (const x of stancErrors.errors || []) {
const marker = stancErrorStringToMarker(x, 'error')
if (marker) codeMarkers.push(marker)
}
for (const x of stancErrors.warnings || []) {
const marker = stancErrorStringToMarker(x, 'warning')
if (marker) codeMarkers.push(marker)
}

return codeMarkers
}

const stancErrorStringToMarker = (x: string, severity: 'error' | 'warning'): CodeMarker | undefined => {
if (!x) return undefined

// Example: Syntax error in 'main.stan', line 1, column 0 to column 1, parsing error:

let lineNumber: number | undefined = undefined
let startColumn: number | undefined = undefined
let endColumn: number | undefined = undefined

const sections = x.split(',').map(x => x.trim())
for (const section of sections) {
if ((section.startsWith('line ')) && (lineNumber === undefined)) {
lineNumber = parseInt(section.slice('line '.length))
}
else if ((section.startsWith('column ')) && (startColumn === undefined)) {
const cols = section.slice('column '.length).split(' to ')
startColumn = parseInt(cols[0])
endColumn = cols.length > 1 ? parseInt(cols[1].slice('column '.length)) : startColumn + 1
}
}

if ((lineNumber !== undefined) && (startColumn !== undefined) && (endColumn !== undefined)) {
return {
startLineNumber: lineNumber,
startColumn: startColumn + 1,
endLineNumber: lineNumber,
endColumn: endColumn + 1,
message: severity === 'warning' ? getWarningMessage(x) : getErrorMessage(x),
severity
}
}
else {
return undefined
}
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Adapted from https://github.com/WardBrian/vscode-stan-extension
function getWarningMessage(message: string) {
let warning = message.replace(/Warning.*column \d+: /s, "");
warning = warning.replace(/\s+/gs, " ");
warning = warning.trim();
warning = message.includes("included from")
? "Warning in included file:\n" + warning
: warning;
return warning;
}

function getErrorMessage(message: string) {
let error = message;
// cut off code snippet for display
if (message.includes("------\n")) {
error = error.split("------\n")[2];
}
error = error.trim();
error = message.includes("included from")
? "Error in included file:\n" + error
: error;

// only relevant to vscode-stan-extension:
// error = error.includes("given information about")
// ? error +
// "\nConsider updating the includePaths setting of vscode-stan-extension"
// : error;

return error;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

export default StanFileEditor
50 changes: 46 additions & 4 deletions gui/src/app/FileEditor/TextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import { Hyperlink, SmallIconButton } from "@fi-sci/misc";

type Monaco = typeof monaco

// An interface for passing markers (squiggles) to the editor without depending on monaco types
export type CodeMarker = {
startLineNumber: number
startColumn: number
endLineNumber: number
endColumn: number
message: string
severity: 'error' | 'warning' | 'hint' | 'info'
}

type Props = {
defaultText?: string
text: string | undefined
Expand All @@ -23,6 +33,7 @@ type Props = {
label: string
width: number
height: number
codeMarkers?: CodeMarker[]
}

export type ToolbarItem = {
Expand All @@ -38,7 +49,7 @@ export type ToolbarItem = {
color?: string
}

const TextEditor: FunctionComponent<Props> = ({defaultText, text, onSaveText, editedText, onSetEditedText, readOnly, wordWrap, onReload, toolbarItems, language, label, width, height}) => {
const TextEditor: FunctionComponent<Props> = ({defaultText, text, onSaveText, editedText, onSetEditedText, readOnly, wordWrap, onReload, toolbarItems, language, label, width, height, codeMarkers}) => {
const handleChange = useCallback((value: string | undefined) => {
onSetEditedText(value || '')
}, [onSetEditedText])
Expand All @@ -60,10 +71,27 @@ const TextEditor: FunctionComponent<Props> = ({defaultText, text, onSaveText, ed
if (editor.getValue() === editedText) return
editor.setValue(editedText || defaultText || '')
}, [editedText, editor, defaultText])
const [monacoInstance, setMonacoInstance] = useState<Monaco | undefined>(undefined)
useEffect(() => {
if (!monacoInstance) return
if (codeMarkers === undefined) return
if (editor === undefined) return
const model = editor.getModel()
if (model === null) return
const modelMarkers = codeMarkers.map(marker => ({
startLineNumber: marker.startLineNumber,
startColumn: marker.startColumn,
endLineNumber: marker.endLineNumber,
endColumn: marker.endColumn,
message: marker.message,
severity: toMonacoMarkerSeverity(marker.severity)
}))
monacoInstance.editor.setModelMarkers(model, 'stan-playground', modelMarkers)
}, [codeMarkers, monacoInstance, editor])
const handleEditorDidMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
setMonacoInstance(monaco);
(async () => {
if (language === 'stan') {

monaco.editor.defineTheme('vs-stan', {
base: 'vs-dark',
inherit: true,
Expand Down Expand Up @@ -140,8 +168,13 @@ const TextEditor: FunctionComponent<Props> = ({defaultText, text, onSaveText, ed
return (
<div style={{position: 'absolute', width, height, overflow: 'hidden'}} onKeyDown={handleKeyDown}>
<NotSelectable>
<div style={{position: 'absolute', paddingLeft: 20, paddingTop: 3, width: width - 50, height: toolbarHeight, backgroundColor: 'lightgray', overflow: 'hidden'}}>
{label}
<div style={{position: 'absolute', paddingLeft: 20, paddingTop: 3, width: width, height: toolbarHeight, backgroundColor: 'lightgray', overflow: 'hidden'}}>
<span
// drop it down a bit
style={{position: 'relative', top: 1}}
>
{label}
</span>
&nbsp;&nbsp;&nbsp;
{!readOnly && (
<SmallIconButton onClick={handleSave} icon={<Save />} title="Save file" disabled={text === editedText} label="save" />
Expand Down Expand Up @@ -181,6 +214,15 @@ const TextEditor: FunctionComponent<Props> = ({defaultText, text, onSaveText, ed
)
}

const toMonacoMarkerSeverity = (s: 'error' | 'warning' | 'hint' | 'info'): monaco.MarkerSeverity => {
switch (s) {
case 'error': return monaco.MarkerSeverity.Error
case 'warning': return monaco.MarkerSeverity.Warning
case 'hint': return monaco.MarkerSeverity.Hint
case 'info': return monaco.MarkerSeverity.Info
}
}

const ToolbarItemComponent: FunctionComponent<{item: ToolbarItem}> = ({item}) => {
if (item.type === 'button') {
const {onClick, color, label, tooltip, icon} = item
Expand Down
24 changes: 15 additions & 9 deletions gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { initialDataModel, SPAnalysisDataModel } from "./SPAnalysisDataModel"
import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react"
import { SPAnalysisReducer, SPAnalysisReducerAction, SPAnalysisReducerType } from "./SPAnalysisReducer"
import { deserializeAnalysisFromLocalStorage, serializeAnalysisToLocalStorage } from "./SPAnalysisSerialization"
import { fetchRemoteAnalysis, queryStringHasParameters, useQueryParams } from "./SPAnalysisQueryLoading"
import { fetchRemoteAnalysis, queryStringHasParameters, fromQueryParams } from "./SPAnalysisQueryLoading"
import { useSearchParams } from "react-router-dom"

type SPAnalysisContextType = {
data: SPAnalysisDataModel
Expand All @@ -20,10 +21,9 @@ export const SPAnalysisContext = createContext<SPAnalysisContextType>({


const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisContextProviderProps>> = ({ children }) => {
const [data, update] = useReducer<SPAnalysisReducerType>(SPAnalysisReducer, initialDataModel)

const { queries, clearSearchParams } = useQueryParams();

const [data, update] = useReducer<SPAnalysisReducerType>(SPAnalysisReducer(clearSearchParams), initialDataModel)
const [searchParams, setSearchParams] = useSearchParams();

useEffect(() => {
// as user reloads the page or closes the tab, save state to local storage
Expand All @@ -39,11 +39,15 @@ const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisC
}, [data])

useEffect(() => {
if (data != initialDataModel) return;

const queries = fromQueryParams(searchParams)
if (queryStringHasParameters(queries)) {
fetchRemoteAnalysis(queries).then((data) => {
update({ type: 'loadInitialData', state: data })
update({ type: 'loadInitialData', state: data });

// set title so that history is better preserved in the browser
document.title = "Stan Playground - " + data.meta.title;
// clear search parameters now that load is complete
setSearchParams(new URLSearchParams());
})
} else {
// load the saved state on first load
Expand All @@ -53,8 +57,10 @@ const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisC
if (!parsedData) return // unsuccessful parse or type cast
update({ type: 'loadInitialData', state: parsedData })
}

}, [data, queries])
// once we have loaded some data, we don't need the localStorage again
// and it will be overwritten by the above event listener on close
localStorage.removeItem('stan-playground-saved-state')
}, [searchParams, setSearchParams])

return (
<SPAnalysisContext.Provider value={{ data, update }}>
Expand Down
15 changes: 2 additions & 13 deletions gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { useSearchParams } from "react-router-dom";
import { SPAnalysisDataModel, initialDataModel, persistStateToEphemera } from "./SPAnalysisDataModel";
import { useCallback } from "react";
import { isSamplingOpts } from "../StanSampler/StanSampler";


Expand All @@ -20,16 +18,7 @@ type QueryParams = {
[key in QueryParamKeys]: string | null
}

export const useQueryParams = () => {
const [searchParams, setSearchParams] = useSearchParams();

const clearSearchParams = useCallback(() => {
// whenever the data state is 'dirty', we want to
// clear the URL bar as to indiciate that the viewed content is
// no longer what the link would point to
if (searchParams.size !== 0)
setSearchParams(new URLSearchParams())
}, [searchParams, setSearchParams]);
export const fromQueryParams = (searchParams: URLSearchParams) => {

for (const key of searchParams.keys()) {
// warn on unknown keys
Expand All @@ -50,7 +39,7 @@ export const useQueryParams = () => {
seed: searchParams.get(QueryParamKeys.SOSeed),
}

return { queries, clearSearchParams }
return queries;
}

export const queryStringHasParameters = (query: QueryParams) => {
Expand Down
11 changes: 2 additions & 9 deletions gui/src/app/SPAnalysis/SPAnalysisReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,7 @@ export type SPAnalysisReducerAction = {
type: 'clear'
}

export const SPAnalysisReducer = (onDirty: () => void) => (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => {
if (a.type !== "loadInitialData") {
// TextEditor seems to trigger occasional spurious edits where nothing changes
if (a.type !== "editFile" || s[a.filename] != a.content) {
onDirty();
}
}

export const SPAnalysisReducer = (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => {
switch (a.type) {
case "loadStanie": {
const dataFileContent = JSON.stringify(a.stanie.data, null, 2);
Expand Down Expand Up @@ -134,4 +127,4 @@ const loadFromProjectFiles = (data: SPAnalysisDataModel, files: Partial<FieldsCo
newData = fileKeys.reduce((currData, currField) => loadFileFromString(currData, currField, files[currField] ?? ''), newData)
newData = persistStateToEphemera(newData)
return newData
}
}
Loading

0 comments on commit 6d6536d

Please sign in to comment.