diff --git a/app/gui/package.json b/app/gui/package.json index 389e9b83fcd9..fab596327b6e 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -92,13 +92,13 @@ "@ag-grid-enterprise/range-selection": "^32.3.3", "@babel/parser": "^7.24.7", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", - "@codemirror/commands": "^6.6.0", - "@codemirror/language": "^6.10.2", + "@codemirror/commands": "^6.7.1", + "@codemirror/language": "^6.10.6", "@codemirror/lang-markdown": "^v6.3.0", - "@codemirror/lint": "^6.8.1", - "@codemirror/search": "^6.5.6", - "@codemirror/state": "^6.4.1", - "@codemirror/view": "^6.35.0", + "@codemirror/lint": "^6.8.4", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", "@fast-check/vitest": "^0.0.8", "@floating-ui/vue": "^1.0.6", "@lezer/common": "^1.1.0", diff --git a/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue index 5279176f20ff..25f3451aafb5 100644 --- a/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue +++ b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue @@ -4,45 +4,58 @@ import { ensoSyntax } from '@/components/CodeEditor/ensoSyntax' import { useEnsoSourceSync } from '@/components/CodeEditor/sync' import { ensoHoverTooltip } from '@/components/CodeEditor/tooltips' import CodeMirrorRoot from '@/components/CodeMirrorRoot.vue' +import VueComponentHost from '@/components/VueComponentHost.vue' import { useGraphStore } from '@/stores/graph' import { useProjectStore } from '@/stores/project' import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { useAutoBlur } from '@/util/autoBlur' import { useCodeMirror } from '@/util/codemirror' +import { highlightStyle } from '@/util/codemirror/highlight' import { testSupport } from '@/util/codemirror/testSupport' -import { indentWithTab } from '@codemirror/commands' -import { - bracketMatching, - defaultHighlightStyle, - foldGutter, - syntaxHighlighting, -} from '@codemirror/language' +import { indentWithTab, insertNewlineKeepIndent } from '@codemirror/commands' +import { bracketMatching, foldGutter } from '@codemirror/language' import { lintGutter } from '@codemirror/lint' import { highlightSelectionMatches } from '@codemirror/search' import { keymap } from '@codemirror/view' -import { type Highlighter } from '@lezer/highlight' import { minimalSetup } from 'codemirror' -import { computed, onMounted, useTemplateRef, type ComponentInstance } from 'vue' +import { + computed, + onMounted, + toRef, + useCssModule, + useTemplateRef, + type ComponentInstance, +} from 'vue' const projectStore = useProjectStore() const graphStore = useGraphStore() const suggestionDbStore = useSuggestionDbStore() + +const vueComponentHost = + useTemplateRef>('vueComponentHost') const editorRoot = useTemplateRef>('editorRoot') const rootElement = computed(() => editorRoot.value?.rootElement) useAutoBlur(rootElement) +const autoindentOnEnter = { + key: 'Enter', + run: insertNewlineKeepIndent, +} + +const vueHost = computed(() => vueComponentHost.value || undefined) const { editorView, setExtraExtensions } = useCodeMirror(editorRoot, { extensions: [ + keymap.of([indentWithTab, autoindentOnEnter]), minimalSetup, - syntaxHighlighting(defaultHighlightStyle as Highlighter), bracketMatching(), foldGutter(), lintGutter(), highlightSelectionMatches(), - ensoSyntax(), - ensoHoverTooltip(graphStore, suggestionDbStore), - keymap.of([indentWithTab]), + ensoSyntax(toRef(graphStore, 'moduleRoot')), + highlightStyle(useCssModule()), + ensoHoverTooltip(graphStore, suggestionDbStore, vueHost), ], + vueHost, }) ;(window as any).__codeEditorApi = testSupport(editorView) const { updateListener, connectModuleListener } = useEnsoSourceSync( @@ -61,6 +74,7 @@ onMounted(() => { + + + diff --git a/app/gui/src/project-view/components/CodeEditor/CodeEditorTooltip.vue b/app/gui/src/project-view/components/CodeEditor/CodeEditorTooltip.vue new file mode 100644 index 000000000000..3b1e79c7593e --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/CodeEditorTooltip.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/gui/src/project-view/components/CodeEditor/diagnostics.ts b/app/gui/src/project-view/components/CodeEditor/diagnostics.ts index 829daebac30c..db4fd41d7511 100644 --- a/app/gui/src/project-view/components/CodeEditor/diagnostics.ts +++ b/app/gui/src/project-view/components/CodeEditor/diagnostics.ts @@ -1,74 +1,20 @@ import { type GraphStore } from '@/stores/graph' import { type ProjectStore } from '@/stores/project' +import { valueExt } from '@/util/codemirror/stateEffect' import { type Diagnostic, forceLinting, linter } from '@codemirror/lint' -import { type Extension, StateEffect, StateField } from '@codemirror/state' +import { type Extension } from '@codemirror/state' import { type EditorView } from '@codemirror/view' import * as iter from 'enso-common/src/utilities/data/iter' -import { computed, watch } from 'vue' -import { type Diagnostic as LSDiagnostic, type Position } from 'ydoc-shared/languageServerTypes' +import { computed, watchEffect } from 'vue' +import { type SourceRange } from 'ydoc-shared/util/data/text' +import { type ExternalId } from 'ydoc-shared/yjsModel' -// Effect that can be applied to the document to invalidate the linter state. -const diagnosticsUpdated = StateEffect.define() -// State value that is perturbed by any `diagnosticsUpdated` effect. -const diagnosticsVersion = StateField.define({ - create: (_state) => 0, - update: (value, transaction) => { - for (const effect of transaction.effects) { - if (effect.is(diagnosticsUpdated)) value += 1 - } - return value - }, -}) - -/** Given a text, indexes it and returns a function for converting between different ways of identifying positions. */ -function stringPosConverter(text: string) { - let pos = 0 - const lineStartIndex: number[] = [] - for (const line of text.split('\n')) { - lineStartIndex.push(pos) - pos += line.length + 1 - } - const length = text.length - - function lineColToIndex({ - line, - character, - }: { - line: number - character: number - }): number | undefined { - const startIx = lineStartIndex[line] - if (startIx == null) return - const ix = startIx + character - if (ix > length) return - return ix - } - - return { lineColToIndex } -} - -/** Convert the Language Server's diagnostics to CodeMirror diagnostics. */ -function lsDiagnosticsToCMDiagnostics( - diagnostics: LSDiagnostic[], - lineColToIndex: (lineCol: Position) => number | undefined, -) { - const results: Diagnostic[] = [] - for (const diagnostic of diagnostics) { - if (!diagnostic.location) continue - const from = lineColToIndex(diagnostic.location.start) - const to = lineColToIndex(diagnostic.location.end) - if (to == null || from == null) { - // Suppress temporary errors if the source is not the version of the document the LS is reporting diagnostics for. - continue - } - const severity = - diagnostic.kind === 'Error' ? 'error' - : diagnostic.kind === 'Warning' ? 'warning' - : 'info' - results.push({ from, to, message: diagnostic.message, severity }) - } - return results -} +const { + set: setDiagnostics, + get: getDiagnostics, + changed: diagnosticsChanged, + extension: stateExt, +} = valueExt(undefined) /** * CodeMirror extension providing diagnostics for an Enso module. Provides CodeMirror diagnostics based on dataflow @@ -79,6 +25,13 @@ export function useEnsoDiagnostics( graphStore: Pick, editorView: EditorView, ): Extension { + function spanOfExternalId(externalId: ExternalId): SourceRange | undefined { + const astId = graphStore.db.idFromExternal(externalId) + if (!astId) return + const span = graphStore.moduleSource.getSpan(astId) + if (!span) return + return span + } const expressionUpdatesDiagnostics = computed(() => { const updates = projectStore.computedValueRegistry.db const panics = updates.type.reverseLookup('Panic') @@ -87,11 +40,9 @@ export function useEnsoDiagnostics( for (const externalId of iter.chain(panics, errors)) { const update = updates.get(externalId) if (!update) continue - const astId = graphStore.db.idFromExternal(externalId) - if (!astId) continue - const span = graphStore.moduleSource.getSpan(astId) + const span = spanOfExternalId(externalId) if (!span) continue - const [from, to] = span + const { from, to } = span switch (update.payload.type) { case 'Panic': { diagnostics.push({ from, to, message: update.payload.message, severity: 'error' }) @@ -108,27 +59,30 @@ export function useEnsoDiagnostics( } return diagnostics }) - // The LS protocol doesn't identify what version of the file updates are in reference to. When diagnostics are - // received from the LS, we map them to the text assuming that they are applicable to the current version of the - // module. This will be correct if there is no one else editing, and we aren't editing faster than the LS can send - // updates. Typing too quickly can result in incorrect ranges, but at idle it should correct itself when we receive - // new diagnostics. - const executionContextDiagnostics = computed(() => { - const { lineColToIndex } = stringPosConverter(graphStore.moduleSource.text) - return lsDiagnosticsToCMDiagnostics(projectStore.diagnostics, lineColToIndex) - }) - watch([executionContextDiagnostics, expressionUpdatesDiagnostics], () => { - editorView.dispatch({ effects: diagnosticsUpdated.of(null) }) + const executionContextDiagnostics = computed(() => + projectStore.diagnostics.flatMap((diagnostic) => { + const span = diagnostic.expressionId && spanOfExternalId(diagnostic.expressionId) + if (!span) return [] + const { from, to } = span + const severity = + diagnostic.kind === 'Error' ? 'error' + : diagnostic.kind === 'Warning' ? 'warning' + : 'info' + return [{ from, to, message: diagnostic.message, severity }] + }), + ) + watchEffect(() => { + const diagnostics = [ + ...expressionUpdatesDiagnostics.value, + ...executionContextDiagnostics.value, + ] + editorView.dispatch({ effects: setDiagnostics.of(diagnostics) }) forceLinting(editorView) }) return [ - diagnosticsVersion, - linter(() => [...executionContextDiagnostics.value, ...expressionUpdatesDiagnostics.value], { - needsRefresh(update) { - return ( - update.state.field(diagnosticsVersion) !== update.startState.field(diagnosticsVersion) - ) - }, + stateExt, + linter((view) => view.state.facet(getDiagnostics) ?? [], { + needsRefresh: diagnosticsChanged, }), ] } diff --git a/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts b/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts index 7f2dfb62668e..42f3a826fcf0 100644 --- a/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts +++ b/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts @@ -1,12 +1,13 @@ -import { RawAstExtended } from '@/util/ast/extended' -import { RawAst } from '@/util/ast/raw' +import { Ast } from '@/util/ast' import { defineLanguageFacet, foldNodeProp, + indentUnit, Language, languageDataProp, LanguageSupport, } from '@codemirror/language' +import { type Extension } from '@codemirror/state' import { type Input, NodeProp, @@ -17,73 +18,117 @@ import { Tree, } from '@lezer/common' import { styleTags, tags } from '@lezer/highlight' -import * as iter from 'enso-common/src/utilities/data/iter' +import { type Ref } from 'vue' +import { spanMapToSpanGetter, tokenSpanGetter } from 'ydoc-shared/ast/idMap' +import { assertDefined } from 'ydoc-shared/util/assert' +import { rangeLength, type SourceRange } from 'ydoc-shared/util/data/text' -const nodeTypes: NodeType[] = [ - ...RawAst.Tree.typeNames.map((name, id) => NodeType.define({ id, name })), - ...RawAst.Token.typeNames.map((name, id) => - NodeType.define({ id: id + RawAst.Tree.typeNames.length, name: 'Token' + name }), - ), -] +const treeTypeNames = Ast.astTypes +const tokenTypeNames = Ast.tokenTypes.map((name) => `Token${name}`) +const nodeTypes: NodeType[] = [...treeTypeNames, ...tokenTypeNames].map((name, id) => + NodeType.define({ id, name }), +) const nodeSet = new NodeSet(nodeTypes).extend( styleTags({ Ident: tags.variableName, - 'Private!': tags.variableName, - Number: tags.number, - 'Wildcard!': tags.variableName, + Wildcard: tags.variableName, + TokenIdent: tags.name, + 'PropertyAccess/TokenIdent': tags.propertyName, + 'NumericLiteral!': tags.number, + 'FunctionDef/Ident': tags.definition(tags.function(tags.variableName)), + 'Assignment/Ident': tags.definition(tags.variableName), + 'Import/TokenIdent': tags.moduleKeyword, + // Strings and comments 'TextLiteral!': tags.string, - OprApp: tags.operator, + TokenTextEscape: tags.escape, + TokenTextStart: tags.docComment, + TokenTextSection: tags.docComment, + TokenNewline: tags.lineComment, + TokenInvalid: tags.invalid, + // Open/close tokens + 'Group/TokenOpenSymbol': tags.paren, + 'Group/TokenCloseSymbol': tags.paren, + 'Vector/TokenOpenSymbol': tags.squareBracket, + 'Vector/TokenCloseSymbol': tags.squareBracket, + // Operator tokens TokenOperator: tags.operator, - 'Assignment/TokenOperator': tags.definitionOperator, - UnaryOprApp: tags.operator, - 'Function/Ident': tags.function(tags.variableName), - ForeignFunction: tags.function(tags.variableName), - 'Import/TokenIdent': tags.function(tags.moduleKeyword), - Export: tags.function(tags.moduleKeyword), - Lambda: tags.function(tags.variableName), - Documented: tags.docComment, - ConstructorDefinition: tags.function(tags.variableName), + TokenDotOperator: tags.operator, + TokenCommaOperator: tags.separator, + TokenUnaryOperator: tags.operator, + TokenAnnotationOperator: tags.operator, + TokenAutoscopeOperator: tags.operator, + TokenLambdaOperator: tags.function(tags.controlOperator), + TokenSuspendedDefaultArguments: tags.controlOperator, + TokenSuspensionOperator: tags.controlOperator, + TokenAssignmentOperator: tags.definitionOperator, + TokenTypeAnnotationOperator: tags.typeOperator, + TokenArrowOperator: tags.typeOperator, + TokenNegationOperator: tags.number, + // Keyword tokens + TokenAllKeyword: tags.moduleKeyword, + TokenCaseKeyword: tags.controlKeyword, + TokenOfKeyword: tags.controlKeyword, + TokenPrivateKeyword: tags.modifier, + TokenTypeKeyword: tags.definitionKeyword, + TokenForeignKeyword: tags.modifier, }), foldNodeProp.add({ - Function: (node) => node.lastChild, - ArgumentBlockApplication: (node) => node, - OperatorBlockApplication: (node) => node, + BodyBlock: (node) => (node.from === 0 ? null : node), }), ) +const typeByName = new Map(nodeSet.types.map((type) => [type.name, type])) -export type AstNode = RawAstExtended +export type AstNode = Ast.Ast | Ast.Token export const astProp = new NodeProp({ perNode: true }) function astToCodeMirrorTree( - nodeSet: NodeSet, - ast: AstNode, + ast: Ast.Ast, + getSpan: (id: Ast.AstId) => SourceRange | undefined, + getTokenSpan: (token: Ast.Token) => SourceRange | undefined, props?: readonly [number | NodeProp, any][] | undefined, ): Tree { - const [start, end] = ast.span() - const children = ast.children() - - const childrenToConvert = iter.tryGetSoleValue(children)?.isToken() ? [] : children + const nodeType = typeByName.get(ast.typeName) + assertDefined(nodeType) + const children = new Array() + const childrenPositions = new Array() + const { from, to } = getSpan(ast.id)! + for (const child of ast.children()) { + if (child instanceof Ast.Ast) { + children.push(astToCodeMirrorTree(child, getSpan, getTokenSpan)) + childrenPositions.push(getSpan(child.id)!.from - from) + } else { + if (child.code().length === 0) continue + const childSpan = getTokenSpan(child) + assertDefined(childSpan) + const tokenTree = tokenToCodeMirrorTree(child, childSpan) + if (tokenTree) { + children.push(tokenTree) + childrenPositions.push(childSpan.from - from) + } + } + } + return new Tree(nodeType, children, childrenPositions, to - from, [ + ...(props ?? []), + [astProp, ast], + ]) +} - return new Tree( - nodeSet.types[ast.inner.type + (ast.isToken() ? RawAst.Tree.typeNames.length : 0)]!, - childrenToConvert.map((child) => astToCodeMirrorTree(nodeSet, child)), - childrenToConvert.map((child) => child.span()[0] - start), - end - start, - [...(props ?? []), [astProp, ast]], - ) +function tokenToCodeMirrorTree(token: Ast.Token, span: SourceRange): Tree | undefined { + if (token.typeName === 'Raw') return + const type = typeByName.get(`Token${token.typeName}`) + assertDefined(type) + return new Tree(type, [], [], rangeLength(span), [[astProp, token]]) } const facet = defineLanguageFacet() class EnsoParser extends Parser { - nodeSet - constructor() { + private cachedCode: string | undefined + private cachedTree: Tree | undefined + constructor(private readonly moduleRoot: Readonly>) { super() - this.nodeSet = nodeSet } - cachedCode: string | undefined - cachedTree: Tree | undefined createParse(input: Input): PartialParse { return { parsedPos: input.length, @@ -93,8 +138,19 @@ class EnsoParser extends Parser { const code = input.read(0, input.length) if (code !== this.cachedCode || this.cachedTree == null) { this.cachedCode = code - const ast = RawAstExtended.parse(code) - this.cachedTree = astToCodeMirrorTree(this.nodeSet, ast, [[languageDataProp, facet]]) + assertDefined(this.moduleRoot.value) + const root = Ast.copyIntoNewModule(this.moduleRoot.value) + const tempModule = root.module + root.module.setRoot(root) + root.syncToCode(code) + const parsedRoot = tempModule.root() + assertDefined(parsedRoot) + const { info: spans } = Ast.printWithSpans(parsedRoot) + const getSpan = spanMapToSpanGetter(spans.nodes) + const getTokenSpan = tokenSpanGetter(spans.tokens) + this.cachedTree = astToCodeMirrorTree(parsedRoot, getSpan, getTokenSpan, [ + [languageDataProp, facet], + ]) } return this.cachedTree }, @@ -102,15 +158,9 @@ class EnsoParser extends Parser { } } -class EnsoLanguage extends Language { - constructor() { - super(facet, new EnsoParser()) - } -} - -const ensoLanguage = new EnsoLanguage() - /** TODO: Add docs */ -export function ensoSyntax() { - return new LanguageSupport(ensoLanguage) +export function ensoSyntax(moduleRoot: Readonly>): Extension { + return new LanguageSupport(new Language(facet, new EnsoParser(moduleRoot)), [ + indentUnit.of(' '), + ]) } diff --git a/app/gui/src/project-view/components/CodeEditor/sync.ts b/app/gui/src/project-view/components/CodeEditor/sync.ts index 0a72cba7daed..a5b3f11cd766 100644 --- a/app/gui/src/project-view/components/CodeEditor/sync.ts +++ b/app/gui/src/project-view/components/CodeEditor/sync.ts @@ -1,10 +1,10 @@ import { type GraphStore } from '@/stores/graph' import { type ProjectStore } from '@/stores/project' -import { useToast } from '@/util/toast.ts' +import { changeSetToTextEdits } from '@/util/codemirror/text' +import { useToast } from '@/util/toast' import { Annotation, ChangeSet, - type ChangeSpec, type EditorSelection, type Extension, type Text, @@ -16,18 +16,6 @@ import { MutableModule } from 'ydoc-shared/ast' import { SourceRangeEdit, textChangeToEdits } from 'ydoc-shared/util/data/text' import { type Origin } from 'ydoc-shared/yjsModel' -function changeSetToTextEdits(changes: ChangeSet) { - const textEdits = new Array() - changes.iterChanges((from, to, _fromB, _toB, insert) => - textEdits.push({ range: [from, to], insert: insert.toString() }), - ) - return textEdits -} - -function textEditToChangeSpec({ range: [from, to], insert }: SourceRangeEdit): ChangeSpec { - return { from, to, insert } -} - // Indicates a change updating the text to correspond to the given module state. const synchronizedModule = Annotation.define() @@ -78,7 +66,7 @@ export function useEnsoSourceSync( currentModule = undefined const viewText = editorView.state.doc.toString() const code = graphStore.moduleSource.text - const changes = textChangeToEdits(viewText, code).map(textEditToChangeSpec) + const changes = textChangeToEdits(viewText, code) console.info('Resetting the editor to the module code.', changes) editorView.dispatch({ changes, @@ -139,7 +127,7 @@ export function useEnsoSourceSync( // If none of the above exit-conditions were reached, the transaction is applicable to our current state. editorView.dispatch({ - changes: textEdits.map(textEditToChangeSpec), + changes: textEdits, annotations: synchronizedModule.of(graphStore.startEdit()), }) } diff --git a/app/gui/src/project-view/components/CodeEditor/tooltips.ts b/app/gui/src/project-view/components/CodeEditor/tooltips.ts index 964beac221ce..1f69dfa026a1 100644 --- a/app/gui/src/project-view/components/CodeEditor/tooltips.ts +++ b/app/gui/src/project-view/components/CodeEditor/tooltips.ts @@ -1,7 +1,10 @@ -import { type AstNode, astProp } from '@/components/CodeEditor/ensoSyntax' -import { type GraphStore, type NodeId } from '@/stores/graph' +import CodeEditorTooltip from '@/components/CodeEditor/CodeEditorTooltip.vue' +import { astProp } from '@/components/CodeEditor/ensoSyntax' +import { type VueHost } from '@/components/VueComponentHost.vue' +import { type GraphStore } from '@/stores/graph' import { type SuggestionDbStore } from '@/stores/suggestionDatabase' -import { qnJoin, tryQualifiedName } from '@/util/qualifiedName' +import { Ast } from '@/util/ast' +import { type ToValue } from '@/util/reactivity' import { syntaxTree } from '@codemirror/language' import { type Extension } from '@codemirror/state' import { @@ -11,13 +14,13 @@ import { type TooltipView, } from '@codemirror/view' import { type SyntaxNode } from '@lezer/common' -import { unwrap } from 'ydoc-shared/util/data/result' -import { rangeEncloses } from 'ydoc-shared/yjsModel' +import * as iter from 'enso-common/src/utilities/data/iter' +import { h, markRaw, toValue } from 'vue' +import { syntaxNodeAncestors } from 'ydoc-shared/util/lezer' /** TODO: Add docs */ function hoverTooltip( create: ( - ast: AstNode, syntax: SyntaxNode, ) => TooltipView | ((view: EditorView) => TooltipView) | null | undefined, ): Extension { @@ -25,9 +28,7 @@ function hoverTooltip( tooltips({ position: 'absolute' }), originalHoverTooltip((view, pos, side) => { const syntaxNode = syntaxTree(view.state).resolveInner(pos, side) - const astNode = syntaxNode.tree?.prop(astProp) - if (astNode == null) return null - const domOrCreate = create(astNode, syntaxNode) + const domOrCreate = create(syntaxNode) if (domOrCreate == null) return null return { @@ -41,62 +42,34 @@ function hoverTooltip( ] } +function codeEditorTooltip(vueHost: VueHost, props: typeof CodeEditorTooltip.props): TooltipView { + const dom = markRaw(document.createElement('div')) + dom.classList.add('CodeEditorTooltip') + const vueHostRegistration = vueHost.register(h(CodeEditorTooltip, props), dom) + return { dom, destroy: vueHostRegistration.unregister } +} + /** @returns A CodeMirror extension that creates tooltips containing type and syntax information for Enso code. */ export function ensoHoverTooltip( graphStore: Pick, - suggestionDbStore: Pick, + suggestionDbStore: SuggestionDbStore, + vueHost: ToValue, ) { - return hoverTooltip((ast, syn) => { - const dom = document.createElement('div') - const astSpan = ast.span() - let foundNode: NodeId | undefined - for (const [id, node] of graphStore.db.nodeIdToNode.entries()) { - const rootSpan = graphStore.moduleSource.getSpan(node.rootExpr.id) - if (rootSpan && rangeEncloses(rootSpan, astSpan)) { - foundNode = id - break - } - } - const expressionInfo = foundNode && graphStore.db.getExpressionInfo(foundNode) - const nodeColor = foundNode && graphStore.db.getNodeColorStyle(foundNode) - - if (foundNode != null) { - dom - .appendChild(document.createElement('div')) - .appendChild(document.createTextNode(`AST ID: ${foundNode}`)) - } - if (expressionInfo != null) { - dom - .appendChild(document.createElement('div')) - .appendChild(document.createTextNode(`Type: ${expressionInfo.typename ?? 'Unknown'}`)) - } - if (expressionInfo?.profilingInfo[0] != null) { - const profile = expressionInfo.profilingInfo[0] - const executionTime = (profile.ExecutionTime.nanoTime / 1_000_000).toFixed(3) - const text = `Execution Time: ${executionTime}ms` - dom.appendChild(document.createElement('div')).appendChild(document.createTextNode(text)) - } - - dom - .appendChild(document.createElement('div')) - .appendChild(document.createTextNode(`Syntax: ${syn.toString()}`)) - const method = expressionInfo?.methodCall?.methodPointer - if (method != null) { - const moduleName = tryQualifiedName(method.module) - const methodName = tryQualifiedName(method.name) - const qualifiedName = qnJoin(unwrap(moduleName), unwrap(methodName)) - const [id] = suggestionDbStore.entries.nameToId.lookup(qualifiedName) - const suggestionEntry = id != null ? suggestionDbStore.entries.get(id) : undefined - if (suggestionEntry != null) { - const groupNode = dom.appendChild(document.createElement('div')) - groupNode.appendChild(document.createTextNode('Group: ')) - const groupNameNode = groupNode.appendChild(document.createElement('span')) - groupNameNode.appendChild(document.createTextNode(`${method.module}.${method.name}`)) - if (nodeColor) { - groupNameNode.style.color = nodeColor - } - } + return hoverTooltip((syn) => { + const vueHostValue = toValue(vueHost) + if (!vueHostValue) { + console.error('Cannot render tooltip without Vue host.') + return } - return { dom } + const enclosingAstNodes = iter.map(syntaxNodeAncestors(syn), (syn) => syn.tree?.prop(astProp)) + const enclosingAsts = iter.filter(enclosingAstNodes, (node) => node instanceof Ast.Ast) + const enclosingExternalIds = iter.map(enclosingAsts, ({ externalId }) => externalId) + const nodeId = iter.find(enclosingExternalIds, graphStore.db.isNodeId.bind(graphStore.db)) + return codeEditorTooltip(vueHostValue, { + nodeId, + syntax: syn.name, + graphDb: graphStore.db, + suggestionDbStore: suggestionDbStore, + }) }) } diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts index 8db2ed69c54b..95584d69df8f 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts @@ -9,6 +9,7 @@ import { makeStaticMethod, } from '@/stores/suggestionDatabase/entry' import { assert } from '@/util/assert' +import { Ast } from '@/util/ast' import { expect, test } from 'vitest' import { ref, type Ref } from 'vue' import type { Opt } from 'ydoc-shared/util/data/opt' @@ -40,28 +41,29 @@ const staticMethod = { } test.each` - code | callSuggestion | subjectSpan | attachedSpan | subjectType | methodName - ${'val1.method val2'} | ${method} | ${[0, 4]} | ${[0, 4]} | ${'local.Project.Type'} | ${'.method'} - ${'local.Project.Type.method val1 val2'} | ${method} | ${[0, 18]} | ${[26, 30]} | ${'local.Project.Type.type'} | ${'.method'} - ${'Type.method val1'} | ${method} | ${[0, 4]} | ${[12, 16]} | ${'local.Project.Type.type'} | ${'.method'} - ${'local.Project.Type.method'} | ${method} | ${[0, 18]} | ${null} | ${'local.Project.Type.type'} | ${'.method'} - ${'foo.method'} | ${method} | ${[0, 3]} | ${null} | ${'local.Project.Type.type'} | ${'.method'} - ${'foo.method'} | ${method} | ${[0, 3]} | ${[0, 3]} | ${'local.Project.Type'} | ${'.method'} - ${'local.Project.Type.static_method val1'} | ${staticMethod} | ${[0, 18]} | ${[0, 18]} | ${'local.Project.Type.type'} | ${'.static_method'} - ${'Type.Con val1'} | ${con} | ${[0, 4]} | ${[0, 4]} | ${'local.Project.Type.type'} | ${'.Con'} - ${'..Con val1'} | ${con} | ${null} | ${null} | ${null} | ${'.Con'} - ${'local.Project.module_method val1'} | ${moduleMethod} | ${[0, 13]} | ${[0, 13]} | ${'local.Project'} | ${'.module_method'} + code | callSuggestion | subjectSpan | attachedSpan | subjectType | methodName + ${'val1.method val2'} | ${method} | ${{ from: 0, to: 4 }} | ${{ from: 0, to: 4 }} | ${'local.Project.Type'} | ${'.method'} + ${'local.Project.Type.method val1 val2'} | ${method} | ${{ from: 0, to: 18 }} | ${{ from: 26, to: 30 }} | ${'local.Project.Type.type'} | ${'.method'} + ${'Type.method val1'} | ${method} | ${{ from: 0, to: 4 }} | ${{ from: 12, to: 16 }} | ${'local.Project.Type.type'} | ${'.method'} + ${'local.Project.Type.method'} | ${method} | ${{ from: 0, to: 18 }} | ${null} | ${'local.Project.Type.type'} | ${'.method'} + ${'foo.method'} | ${method} | ${{ from: 0, to: 3 }} | ${null} | ${'local.Project.Type.type'} | ${'.method'} + ${'foo.method'} | ${method} | ${{ from: 0, to: 3 }} | ${{ from: 0, to: 3 }} | ${'local.Project.Type'} | ${'.method'} + ${'local.Project.Type.static_method val1'} | ${staticMethod} | ${{ from: 0, to: 18 }} | ${{ from: 0, to: 18 }} | ${'local.Project.Type.type'} | ${'.static_method'} + ${'Type.Con val1'} | ${con} | ${{ from: 0, to: 4 }} | ${{ from: 0, to: 4 }} | ${'local.Project.Type.type'} | ${'.Con'} + ${'..Con val1'} | ${con} | ${null} | ${null} | ${null} | ${'.Con'} + ${'local.Project.module_method val1'} | ${moduleMethod} | ${{ from: 0, to: 13 }} | ${{ from: 0, to: 13 }} | ${'local.Project'} | ${'.module_method'} `( 'Visualization config for $code', ({ code, callSuggestion, subjectSpan, attachedSpan, subjectType, methodName }) => { const spans = { - entireFunction: [0, code.length] as [number, number], - ...(subjectSpan != null ? { subject: subjectSpan as [number, number] } : {}), - ...(attachedSpan != null ? { attached: attachedSpan as [number, number] } : {}), + entireFunction: { from: 0, to: code.length }, + ...(subjectSpan != null ? { subject: subjectSpan } : {}), + ...(attachedSpan != null ? { attached: attachedSpan } : {}), } const { ast, eid, id } = parseWithSpans(code, spans) - const node = (ast.lines[0]?.statement?.node as Ast.ExpressionStatement).expression - assert(node != null) + const statement = ast.lines[0]?.statement?.node + assert(statement instanceof Ast.ExpressionStatement) + const node = statement.expression expect(node.externalId).toBe(eid('entireFunction')) let visConfig: Ref> | undefined diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetHierarchy.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetHierarchy.vue index acd2e8f5e2fc..5491750257ff 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetHierarchy.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetHierarchy.vue @@ -7,7 +7,7 @@ import { isToken } from 'ydoc-shared/ast' const props = defineProps(widgetProps(widgetDefinition)) -const spanClass = computed(() => props.input.value.typeName()) +const spanClass = computed(() => props.input.value.typeName) function* expressionChildren(expression: Ast.Expression) { for (const child of expression.children()) { diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetToken.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetToken.vue index 810a52392c95..3818c9c1fa25 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetToken.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetToken.vue @@ -4,7 +4,7 @@ import { computed } from 'vue' const props = defineProps(widgetProps(widgetDefinition)) -const spanClass = computed(() => props.input.value.typeName()) +const spanClass = computed(() => props.input.value.typeName) const repr = computed(() => props.input.value.code()) diff --git a/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts b/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts index 0d0792a8519c..d88f17c50751 100644 --- a/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts +++ b/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts @@ -41,19 +41,19 @@ test('Reading graph from definition', () => { node3 = node2 + 1 node3` const spans = { - functionName: [0, 8] as [number, number], - parameter: [9, 10] as [number, number], - node1Id: [17, 22] as [number, number], - node1Content: [25, 30] as [number, number], - node1LParam: [25, 26] as [number, number], - node1RParam: [29, 30] as [number, number], - node2Id: [35, 40] as [number, number], - node2Content: [43, 52] as [number, number], - node2LParam: [43, 48] as [number, number], - node2RParam: [51, 52] as [number, number], - node3Id: [57, 62] as [number, number], - node3Content: [65, 74] as [number, number], - output: [79, 84] as [number, number], + functionName: { from: 0, to: 8 }, + parameter: { from: 9, to: 10 }, + node1Id: { from: 17, to: 22 }, + node1Content: { from: 25, to: 30 }, + node1LParam: { from: 25, to: 26 }, + node1RParam: { from: 29, to: 30 }, + node2Id: { from: 35, to: 40 }, + node2Content: { from: 43, to: 52 }, + node2LParam: { from: 43, to: 48 }, + node2RParam: { from: 51, to: 52 }, + node3Id: { from: 57, to: 62 }, + node3Content: { from: 65, to: 74 }, + output: { from: 79, to: 84 }, } const { ast, id, eid, getSpan } = parseWithSpans(code, spans) diff --git a/app/gui/src/project-view/util/ast/__tests__/aliasAnalysis.test.ts b/app/gui/src/project-view/util/ast/__tests__/aliasAnalysis.test.ts index d0a0755da692..fd7b73c03372 100644 --- a/app/gui/src/project-view/util/ast/__tests__/aliasAnalysis.test.ts +++ b/app/gui/src/project-view/util/ast/__tests__/aliasAnalysis.test.ts @@ -29,7 +29,7 @@ import { assertDefined } from '@/util/assert' import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' import { MappedKeyMap, MappedSet } from '@/util/containers' import { expect, test } from 'vitest' -import { sourceRangeKey, type SourceRange } from 'ydoc-shared/yjsModel' +import { sourceRangeKey, type SourceRange } from 'ydoc-shared/util/data/text' /** The type of annotation. */ enum AnnotationType { @@ -86,9 +86,9 @@ function parseAnnotations(annotatedCode: string): { const name = bindingName ?? usageName ?? '' const kind = bindingPrefix != null ? AnnotationType.Binding : AnnotationType.Usage - const start = offset - accumulatedOffset - const end = start + name.length - const range: SourceRange = [start, end] + const from = offset - accumulatedOffset + const to = from + name.length + const range: SourceRange = { from, to } const annotation = new Annotation(kind, id) accumulatedOffset += match.length - name.length @@ -152,8 +152,8 @@ class TestCase { return testCase } - repr(range: SourceRange): string { - return this.code.substring(range[0], range[1]) + repr({ from, to }: SourceRange): string { + return this.code.substring(from, to) } prettyPrint(range: SourceRange): string { @@ -223,22 +223,23 @@ test('Annotations parsing', () => { assertDefined(a, `No annotation found at [${range}].`) expect(a.kind, 'Invalid annotation kind.').toBe(kind) expect(a.id, 'Invalid annotation prefix.').toBe(prefix) - expect(unannotatedCode.substring(range[0], range[1]), 'Invalid annotation identifier.').toBe( - identifier, - ) + expect( + unannotatedCode.substring(range.from, range.to), + 'Invalid annotation identifier.', + ).toBe(identifier) } catch (e) { const message = `Invalid annotation at [${range}]: ${e}` throw new Error(message) } } - validateAnnotation([11, 12], AnnotationType.Binding, 1, 'x') - validateAnnotation([21, 22], AnnotationType.Binding, 2, 'y') - validateAnnotation([35, 36], AnnotationType.Binding, 3, 'x') - validateAnnotation([40, 41], AnnotationType.Usage, 3, 'x') - validateAnnotation([44, 45], AnnotationType.Usage, 2, 'y') - validateAnnotation([54, 55], AnnotationType.Usage, 1, 'x') - validateAnnotation([58, 59], AnnotationType.Usage, 2, 'y') + validateAnnotation({ from: 11, to: 12 }, AnnotationType.Binding, 1, 'x') + validateAnnotation({ from: 21, to: 22 }, AnnotationType.Binding, 2, 'y') + validateAnnotation({ from: 35, to: 36 }, AnnotationType.Binding, 3, 'x') + validateAnnotation({ from: 40, to: 41 }, AnnotationType.Usage, 3, 'x') + validateAnnotation({ from: 44, to: 45 }, AnnotationType.Usage, 2, 'y') + validateAnnotation({ from: 54, to: 55 }, AnnotationType.Usage, 1, 'x') + validateAnnotation({ from: 58, to: 59 }, AnnotationType.Usage, 2, 'y') }) function runTestCase(code: string) { diff --git a/app/gui/src/project-view/util/ast/abstract.ts b/app/gui/src/project-view/util/ast/abstract.ts index ec64613798d8..9dd919ce9b8f 100644 --- a/app/gui/src/project-view/util/ast/abstract.ts +++ b/app/gui/src/project-view/util/ast/abstract.ts @@ -348,7 +348,7 @@ export function parseUpdatingIdMap( if (idMap) setExternalIds(root.module, spans, idMap) return { root, spans } }) - const getSpan = spanMapToSpanGetter(spans) + const getSpan = spanMapToSpanGetter(spans.nodes) const idMapOut = spanMapToIdMap(spans) return { root, idMap: idMapOut, getSpan } } diff --git a/app/gui/src/project-view/util/ast/aliasAnalysis.ts b/app/gui/src/project-view/util/ast/aliasAnalysis.ts index 0ab8e0bc8b0f..06c029fb100b 100644 --- a/app/gui/src/project-view/util/ast/aliasAnalysis.ts +++ b/app/gui/src/project-view/util/ast/aliasAnalysis.ts @@ -8,7 +8,7 @@ import { } from '@/util/ast/raw' import { MappedKeyMap, MappedSet, NonEmptyStack } from '@/util/containers' import { LazyObject } from 'ydoc-shared/ast/parserSupport' -import { rangeIsBefore, sourceRangeKey, type SourceRange } from 'ydoc-shared/yjsModel' +import { rangeIsBefore, sourceRangeKey, type SourceRange } from 'ydoc-shared/util/data/text' const ACCESSOR_OPERATOR = '.' @@ -306,13 +306,14 @@ export class AliasAnalyzer { const arrow = caseLine.case?.arrow const expression = caseLine.case?.expression if (pattern) { - const armStart = parsedTreeOrTokenRange(pattern)[0] - const armEnd = - expression ? parsedTreeOrTokenRange(expression)[1] - : arrow ? parsedTreeOrTokenRange(arrow)[1] - : parsedTreeOrTokenRange(pattern)[1] - - const armRange: SourceRange = [armStart, armEnd] + const patternRange = parsedTreeOrTokenRange(pattern) + const armRange: SourceRange = { + from: patternRange.from, + to: (expression ? parsedTreeOrTokenRange(expression) + : arrow ? parsedTreeOrTokenRange(arrow) + : patternRange + ).to, + } this.withNewScopeOver(armRange, () => { this.withContext(Context.Pattern, () => { this.processTree(caseLine.case?.pattern) diff --git a/app/gui/src/project-view/util/ast/bindings.ts b/app/gui/src/project-view/util/ast/bindings.ts index d7c3382ed25e..fee7a318515a 100644 --- a/app/gui/src/project-view/util/ast/bindings.ts +++ b/app/gui/src/project-view/util/ast/bindings.ts @@ -1,11 +1,11 @@ import { Ast, RawAst } from '@/util/ast' import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' -import { visitRecursive } from '@/util/ast/raw' +import { parsedTreeRange, visitRecursive } from '@/util/ast/raw' import { MappedKeyMap, MappedSet } from '@/util/containers' -import type { AstId } from 'ydoc-shared/ast' -import type { SourceDocument } from 'ydoc-shared/ast/sourceDocument' +import { type AstId } from 'ydoc-shared/ast' +import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument' import { assert, assertDefined } from 'ydoc-shared/util/assert' -import { type SourceRange, sourceRangeKey, type SourceRangeKey } from 'ydoc-shared/yjsModel' +import { type SourceRange, sourceRangeKey, type SourceRangeKey } from 'ydoc-shared/util/data/text' /** A variable name, and information about its usages. */ export interface BindingInfo { @@ -21,9 +21,7 @@ export function analyzeBindings( const toRaw = new Map() visitRecursive(Ast.rawParseModule(moduleSource.text), (node) => { if (node.type === RawAst.Tree.Type.Function) { - const start = node.whitespaceStartInCodeParsed + node.whitespaceLengthInCodeParsed - const end = start + node.childrenLengthInCodeParsed - toRaw.set(sourceRangeKey([start, end]), node) + toRaw.set(sourceRangeKey(parsedTreeRange(node)), node) return false } return true diff --git a/app/gui/src/project-view/util/ast/extended.ts b/app/gui/src/project-view/util/ast/extended.ts index 623f8190f196..bbbe49718267 100644 --- a/app/gui/src/project-view/util/ast/extended.ts +++ b/app/gui/src/project-view/util/ast/extended.ts @@ -16,7 +16,8 @@ import * as map from 'lib0/map' import { markRaw } from 'vue' import * as Ast from 'ydoc-shared/ast/generated/ast' import { Token, Tree } from 'ydoc-shared/ast/generated/ast' -import type { ExternalId, IdMap, SourceRange } from 'ydoc-shared/yjsModel' +import { type SourceRange } from 'ydoc-shared/util/data/text' +import { type ExternalId, type IdMap } from 'ydoc-shared/yjsModel' export { AstExtended as RawAstExtended } @@ -194,7 +195,7 @@ class AstExtendedCtx { } static getHashKey(ast: AstExtended) { - return `${ast.isToken() ? 'T.' : ''}${ast.inner.type}.${ast.span()[0]}` + return `${ast.isToken() ? 'T.' : ''}${ast.inner.type}.${ast.span().from}` } getHash(ast: AstExtended) { diff --git a/app/gui/src/project-view/util/ast/raw.ts b/app/gui/src/project-view/util/ast/raw.ts index f6197bbbde4b..6c5307e98170 100644 --- a/app/gui/src/project-view/util/ast/raw.ts +++ b/app/gui/src/project-view/util/ast/raw.ts @@ -1,7 +1,7 @@ import * as RawAst from 'ydoc-shared/ast/generated/ast' import { rawParseModule } from 'ydoc-shared/ast/parse' import { LazyObject } from 'ydoc-shared/ast/parserSupport' -import type { SourceRange } from 'ydoc-shared/yjsModel' +import { type SourceRange } from 'ydoc-shared/util/data/text' export { RawAst, rawParseModule } @@ -15,7 +15,7 @@ export type HasAstRange = SourceRange | RawAst.Tree | RawAst.Token */ export function readAstOrTokenSpan(node: RawAst.Tree | RawAst.Token, code: string): string { const range = parsedTreeOrTokenRange(node) - return code.substring(range[0], range[1]) + return code.substring(range.from, range.to) } /** @@ -25,18 +25,7 @@ export function readAstOrTokenSpan(node: RawAst.Tree | RawAst.Token, code: strin */ export function readTokenSpan(token: RawAst.Token, code: string): string { const range = parsedTokenRange(token) - return code.substring(range[0], range[1]) -} - -/** Read direct AST children. */ -export function childrenAstNodes(obj: LazyObject): RawAst.Tree[] { - const children: RawAst.Tree[] = [] - const visitor = (obj: LazyObject) => { - if (RawAst.Tree.isInstance(obj)) children.push(obj) - else if (!RawAst.Token.isInstance(obj)) obj.visitChildren(visitor) - } - obj.visitChildren(visitor) - return children + return code.substring(range.from, range.to) } /** TODO: Add docs */ @@ -90,20 +79,20 @@ export function visitRecursive( /** * Read ast span information in `String.substring` compatible way. The returned span does not * include left whitespace offset. - * @returns Object with `start` and `end` properties; index of first character in the `node` + * @returns Object with `from` and `to` properties; index of first character in the `node` * and first character _not_ being in the `node`. */ -function parsedTreeRange(tree: RawAst.Tree): SourceRange { - const start = tree.whitespaceStartInCodeParsed + tree.whitespaceLengthInCodeParsed - const end = start + tree.childrenLengthInCodeParsed - return [start, end] +export function parsedTreeRange(tree: RawAst.Tree): SourceRange { + const from = tree.whitespaceStartInCodeParsed + tree.whitespaceLengthInCodeParsed + const to = from + tree.childrenLengthInCodeParsed + return { from, to } } /** TODO: Add docs */ function parsedTokenRange(token: RawAst.Token): SourceRange { - const start = token.startInCodeBuffer - const end = start + token.lengthInCodeBuffer - return [start, end] + const from = token.startInCodeBuffer + const to = from + token.lengthInCodeBuffer + return { from, to } } /** TODO: Add docs */ diff --git a/app/gui/src/project-view/util/codemirror/index.ts b/app/gui/src/project-view/util/codemirror/index.ts index ea8f99b40ae7..2a220fd08784 100644 --- a/app/gui/src/project-view/util/codemirror/index.ts +++ b/app/gui/src/project-view/util/codemirror/index.ts @@ -7,7 +7,7 @@ import { setVueHost } from '@/util/codemirror/vueHostExt' import { yCollab } from '@/util/codemirror/yCollab' import { elementHierarchy } from '@/util/dom' import { ToValue } from '@/util/reactivity' -import { Compartment, EditorState, Extension, Text } from '@codemirror/state' +import { Compartment, EditorState, type Extension, Text } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { LINE_BOUNDARIES } from 'enso-common/src/utilities/data/string' import { @@ -40,7 +40,7 @@ export function useCodeMirror( /** If a value is provided, the editor state will be synchronized with it. */ content?: ToValue /** CodeMirror {@link Extension}s to include in the editor's initial state. */ - extensions?: Extension[] + extensions?: Extension /** If a value is provided, it will be made available to extensions that render Vue components. */ vueHost?: WatchSource }, diff --git a/app/gui/src/project-view/util/codemirror/text.ts b/app/gui/src/project-view/util/codemirror/text.ts new file mode 100644 index 000000000000..7bafd28574df --- /dev/null +++ b/app/gui/src/project-view/util/codemirror/text.ts @@ -0,0 +1,20 @@ +import { type ChangeDesc, type ChangeSet } from '@codemirror/state' +import { type SourceRangeEdit, type SourceRangeEditDesc } from 'ydoc-shared/util/data/text' + +/** Collect the changes in a {@link ChangeSet} as {@link SourceRangeEdit}s. */ +export function changeSetToTextEdits(changes: ChangeSet): SourceRangeEdit[] { + const textEdits = new Array() + changes.iterChanges((from, to, _fromB, _toB, insert) => + textEdits.push({ from, to, insert: insert.toString() }), + ) + return textEdits +} + +/** Collect the change descriptions in a {@link ChangeDesc} as {@link SourceRangeEditDesc}s. */ +export function changeDescToSourceRangeEditDesc(changeDesc: ChangeDesc): SourceRangeEditDesc[] { + const textEdits = new Array() + changeDesc.iterChangedRanges((fromA, toA, fromB, toB) => { + textEdits.push({ from: fromA, to: toA, insert: { length: toB - fromB } }) + }) + return textEdits +} diff --git a/app/ydoc-server/src/serialization.ts b/app/ydoc-server/src/serialization.ts index 0cf94c3626a9..eb3d465f0f08 100644 --- a/app/ydoc-server/src/serialization.ts +++ b/app/ydoc-server/src/serialization.ts @@ -1,7 +1,8 @@ /** Translation of `yjsModel` types to and from the `fileFormat` representation. */ import * as json from 'lib0/json' -import { ExternalId, IdMap, sourceRangeFromKey } from 'ydoc-shared/yjsModel' +import { sourceRangeFromKey } from 'ydoc-shared/util/data/text' +import { type ExternalId, IdMap } from 'ydoc-shared/yjsModel' import * as fileFormat from './fileFormat' /** Convert a JSON string to an {@link IdMap}. */ @@ -14,7 +15,7 @@ export function deserializeIdMap(idMapJson: string): IdMap { console.error(`Invalid range for id ${id}:`, range) continue } - idMap.insertKnownId([index.value, index.value + size.value], id as ExternalId) + idMap.insertKnownId({ from: index.value, to: index.value + size.value }, id as ExternalId) } return idMap } @@ -30,8 +31,8 @@ export function idMapToArray(map: IdMap): fileFormat.IdMapEntry[] { const entries: fileFormat.IdMapEntry[] = [] map.entries().forEach(([rangeBuffer, id]) => { const decoded = sourceRangeFromKey(rangeBuffer) - const index = decoded[0] - const endIndex = decoded[1] + const index = decoded.from + const endIndex = decoded.to if (index == null || endIndex == null) return const size = endIndex - index entries.push([{ index: { value: index }, size: { value: size } }, id]) diff --git a/app/ydoc-shared/src/__tests__/yjsModel.test.ts b/app/ydoc-shared/src/__tests__/yjsModel.test.ts deleted file mode 100644 index 7f1170439f20..000000000000 --- a/app/ydoc-shared/src/__tests__/yjsModel.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from 'vitest' -import { rangeEncloses, rangeIntersects, type SourceRange } from '../yjsModel' - -type RangeTest = { a: SourceRange; b: SourceRange } - -const equalRanges: RangeTest[] = [ - { a: [0, 0], b: [0, 0] }, - { a: [0, 1], b: [0, 1] }, - { a: [-5, 5], b: [-5, 5] }, -] - -const totalOverlap: RangeTest[] = [ - { a: [0, 1], b: [0, 0] }, - { a: [0, 2], b: [2, 2] }, - { a: [-1, 1], b: [1, 1] }, - { a: [0, 2], b: [0, 1] }, - { a: [-10, 10], b: [-3, 7] }, - { a: [0, 5], b: [1, 2] }, - { a: [3, 5], b: [3, 4] }, -] - -const reverseTotalOverlap: RangeTest[] = totalOverlap.map(({ a, b }) => ({ a: b, b: a })) - -const noOverlap: RangeTest[] = [ - { a: [0, 1], b: [2, 3] }, - { a: [0, 1], b: [-1, -1] }, - { a: [5, 6], b: [2, 3] }, - { a: [0, 2], b: [-2, -1] }, - { a: [-5, -3], b: [9, 10] }, - { a: [-3, 2], b: [3, 4] }, -] - -const partialOverlap: RangeTest[] = [ - { a: [0, 3], b: [-1, 1] }, - { a: [0, 1], b: [-1, 0] }, - { a: [0, 0], b: [-1, 0] }, - { a: [0, 2], b: [1, 4] }, - { a: [-8, 0], b: [0, 10] }, -] - -test.each([...equalRanges, ...totalOverlap])('Range $a should enclose $b', ({ a, b }) => - expect(rangeEncloses(a, b)).toBe(true), -) -test.each([...noOverlap, ...partialOverlap, ...reverseTotalOverlap])( - 'Range $a should not enclose $b', - ({ a, b }) => expect(rangeEncloses(a, b)).toBe(false), -) -test.each([...equalRanges, ...totalOverlap, ...reverseTotalOverlap, ...partialOverlap])( - 'Range $a should intersect $b', - ({ a, b }) => expect(rangeIntersects(a, b)).toBe(true), -) -test.each([...noOverlap])('Range $a should not intersect $b', ({ a, b }) => - expect(rangeIntersects(a, b)).toBe(false), -) diff --git a/app/ydoc-shared/src/ast/idMap.ts b/app/ydoc-shared/src/ast/idMap.ts index 9caa1437979b..5042da3571e7 100644 --- a/app/ydoc-shared/src/ast/idMap.ts +++ b/app/ydoc-shared/src/ast/idMap.ts @@ -1,13 +1,12 @@ import * as random from 'lib0/random' import { - type ExternalId, type SourceRange, type SourceRangeKey, - IdMap, sourceRangeFromKey, sourceRangeKey, -} from '../yjsModel' -import { type Token } from './token' +} from '../util/data/text' +import { type ExternalId, IdMap } from '../yjsModel' +import { type Token, type TokenId } from './token' import { type Ast, type AstId, ExpressionStatement } from './tree' declare const nodeKeyBrand: unique symbol @@ -18,11 +17,11 @@ declare const tokenKeyBrand: unique symbol export type TokenKey = SourceRangeKey & { [tokenKeyBrand]: never } /** Create a source-range key for an `Ast`. */ export function nodeKey(start: number, length: number): NodeKey { - return sourceRangeKey([start, start + length]) as NodeKey + return sourceRangeKey({ from: start, to: start + length }) as NodeKey } /** Create a source-range key for a `Token`. */ export function tokenKey(start: number, length: number): TokenKey { - return sourceRangeKey([start, start + length]) as TokenKey + return sourceRangeKey({ from: start, to: start + length }) as TokenKey } /** Maps from source ranges to `Ast`s. */ @@ -56,13 +55,20 @@ export function spanMapToIdMap(spans: SpanMap): IdMap { return idMap } -/** Given a `SpanMap`, return a function that can look up source ranges by AST ID. */ -export function spanMapToSpanGetter(spans: SpanMap): (id: AstId) => SourceRange | undefined { +/** Returns a function that can look up source ranges by AST ID. */ +export function spanMapToSpanGetter(spans: NodeSpanMap): (id: AstId) => SourceRange | undefined { const reverseMap = new Map() - for (const [key, asts] of spans.nodes) { + for (const [key, asts] of spans) { for (const ast of asts) { reverseMap.set(ast.id, sourceRangeFromKey(key)) } } return id => reverseMap.get(id) } + +/** Returns a function that can look up token source ranges. */ +export function tokenSpanGetter(spans: TokenSpanMap): (token: Token) => SourceRange | undefined { + const reverseMap = new Map() + for (const [key, token] of spans) reverseMap.set(token.id, sourceRangeFromKey(key)) + return ({ id }) => reverseMap.get(id) +} diff --git a/app/ydoc-shared/src/ast/mutableModule.ts b/app/ydoc-shared/src/ast/mutableModule.ts index 748b7e56b7ed..71ee3cbc3dc9 100644 --- a/app/ydoc-shared/src/ast/mutableModule.ts +++ b/app/ydoc-shared/src/ast/mutableModule.ts @@ -2,26 +2,24 @@ import * as random from 'lib0/random' import * as Y from 'yjs' import { subtreeRoots } from '.' import { assert, assertDefined } from '../util/assert' -import type { SourceRangeEdit } from '../util/data/text' -import { defaultLocalOrigin, tryAsOrigin, type Origin } from '../yjsModel' +import { type SourceRangeEdit } from '../util/data/text' +import { type Origin, defaultLocalOrigin, tryAsOrigin } from '../yjsModel' import { newExternalId } from './idMap' import { parseModule } from './parse' -import type { SyncTokenId } from './token' -import { Token, isTokenId } from './token' -import type { - AstFields, - AstId, - BodyBlock, - FixedMap, - Mutable, - MutableAst, - MutableBodyBlock, - MutableInvalid, - NodeChild, - Owned, - RawNodeChild, -} from './tree' +import { type SyncTokenId, Token, isTokenId } from './token' import { + type AstFields, + type AstId, + type AstType, + type BodyBlock, + type FixedMap, + type Mutable, + type MutableAst, + type MutableBodyBlock, + type MutableInvalid, + type NodeChild, + type Owned, + type RawNodeChild, Ast, Wildcard, asOwned, @@ -160,7 +158,7 @@ export class MutableModule implements Module { /** Copy the given node into the module. */ copy(ast: T): Owned> { - const id = newAstId(ast.typeName()) + const id = newAstId(ast.typeName) const fields = ast.fields.clone() this.nodes.set(id, fields as any) fields.set('id', id) @@ -373,7 +371,7 @@ export class MutableModule implements Module { } /** @internal */ - baseObject(type: string, overrideId?: AstId): FixedMap { + baseObject(type: AstType, overrideId?: AstId): FixedMap { const map = new Y.Map() const map_ = map as unknown as FixedMap const id = overrideId ?? newAstId(type) diff --git a/app/ydoc-shared/src/ast/repair.ts b/app/ydoc-shared/src/ast/repair.ts index f6ed0f10a364..4d4b1f5db681 100644 --- a/app/ydoc-shared/src/ast/repair.ts +++ b/app/ydoc-shared/src/ast/repair.ts @@ -1,12 +1,11 @@ import { subtreeRoots } from '.' import { assertDefined, assertEqual } from '../util/assert' -import { sourceRangeFromKey } from '../yjsModel' -import type { NodeKey, NodeSpanMap } from './idMap' -import type { MutableModule } from './mutableModule' +import { sourceRangeFromKey } from '../util/data/text' +import { type NodeKey, type NodeSpanMap } from './idMap' +import { type MutableModule } from './mutableModule' import { parseModuleWithSpans } from './parse' import { printWithSpans } from './print' -import type { Ast, AstId } from './tree' -import { BodyBlock, Group } from './tree' +import { BodyBlock, Group, type Ast, type AstId } from './tree' /** * Try to find all the spans in `expected` in `encountered`. If any are missing, use the provided `code` to determine @@ -24,10 +23,10 @@ function checkSpans(expected: NodeSpanMap, encountered: NodeSpanMap, code: strin const lostInline = new Array() const lostBlock = new Array() for (const [key, ast] of lost) { - const [start, end] = sourceRangeFromKey(key) + const { from, to } = sourceRangeFromKey(key) // Do not report lost empty body blocks, we don't want them to be considered for repair. - if (start === end && ast instanceof BodyBlock) continue - ;(code.substring(start, end).match(/[\r\n]/) ? lostBlock : lostInline).push(ast) + if (from === to && ast instanceof BodyBlock) continue + ;(code.substring(from, to).match(/[\r\n]/) ? lostBlock : lostInline).push(ast) } return { lostInline, lostBlock } } diff --git a/app/ydoc-shared/src/ast/sourceDocument.ts b/app/ydoc-shared/src/ast/sourceDocument.ts index 32a093625830..88ef330a4d5f 100644 --- a/app/ydoc-shared/src/ast/sourceDocument.ts +++ b/app/ydoc-shared/src/ast/sourceDocument.ts @@ -1,11 +1,16 @@ import { assertDefined } from '../util/assert' -import type { SourceRangeEdit } from '../util/data/text' -import { offsetEdit, textChangeToEdits } from '../util/data/text' -import type { Origin, SourceRange } from '../yjsModel' -import { rangeEquals, sourceRangeFromKey } from '../yjsModel' -import type { Module, ModuleUpdate } from './mutableModule' +import { + rangeEquals, + sourceRangeFromKey, + textChangeToEdits, + translateRange, + type SourceRange, + type SourceRangeEdit, +} from '../util/data/text' +import { type Origin } from '../yjsModel' +import { type Module, type ModuleUpdate } from './mutableModule' import { printWithSpans } from './print' -import type { AstId } from './tree' +import { type AstId } from './tree' /** * Provides a view of the text representation of a module, @@ -32,9 +37,9 @@ export class SourceDocument { clear() { if (this.state.spans.size !== 0) this.state.spans.clear() if (this.state.text !== '') { - const range: SourceRange = [0, this.state.text.length] + const textEdit = { from: 0, to: this.state.text.length, insert: '' } this.state.text = '' - this.notifyObservers([{ range, insert: '' }], undefined) + this.notifyObservers([textEdit], undefined) } } @@ -52,10 +57,10 @@ export class SourceDocument { if (!oldSpan || !rangeEquals(range, oldSpan)) this.state.spans.set(node.id, range) if (update.updateRoots.has(node.id) && node.id !== root.id) { assertDefined(oldSpan) - const oldCode = this.rawState.text.slice(oldSpan[0], oldSpan[1]) - const newCode = printed.code.slice(range[0], range[1]) + const oldCode = this.rawState.text.slice(oldSpan.from, oldSpan.to) + const newCode = printed.code.slice(range.from, range.to) const subedits = textChangeToEdits(oldCode, newCode).map(textEdit => - offsetEdit(textEdit, oldSpan[0]), + translateRange(textEdit, oldSpan.from), ) subtreeTextEdits.push(...subedits) } @@ -64,7 +69,7 @@ export class SourceDocument { if (printed.code !== this.rawState.text) { const textEdits = update.updateRoots.has(root.id) ? - [{ range: [0, this.rawState.text.length] satisfies SourceRange, insert: printed.code }] + [{ from: 0, to: this.rawState.text.length, insert: printed.code }] : subtreeTextEdits this.state.text = printed.code this.notifyObservers(textEdits, update.origin) @@ -85,7 +90,7 @@ export class SourceDocument { observe(observer: SourceDocumentObserver) { this.observers.push(observer) if (this.rawState.text.length) - observer([{ range: [0, 0], insert: this.rawState.text }], undefined) + observer([{ from: 0, to: 0, insert: this.rawState.text }], undefined) } /** Remove a callback to no longer be called with a list of edits on every update. */ diff --git a/app/ydoc-shared/src/ast/syncToCode.ts b/app/ydoc-shared/src/ast/syncToCode.ts index 77c5feb376d2..ee08c41bb33e 100644 --- a/app/ydoc-shared/src/ast/syncToCode.ts +++ b/app/ydoc-shared/src/ast/syncToCode.ts @@ -2,21 +2,20 @@ import * as iter from 'enso-common/src/utilities/data/iter' import * as map from 'lib0/map' import { assert, assertDefined } from '../util/assert' import { + type SourceRange, type SourceRangeEdit, + type SourceRangeEditDesc, + type SourceRangeKey, type SpanTree, applyTextEdits, applyTextEditsToSpans, enclosingSpans, - textChangeToEdits, - trimEnd, -} from '../util/data/text' -import { - type SourceRange, - type SourceRangeKey, rangeLength, sourceRangeFromKey, sourceRangeKey, -} from '../yjsModel' + textChangeToEdits, + trimEnd, +} from '../util/data/text' import { xxHash128 } from './ffi' import { type NodeKey, type NodeSpanMap, newExternalId } from './idMap' import type { Module, MutableModule } from './mutableModule' @@ -88,7 +87,7 @@ function calculateCorrespondence( astSpans: NodeSpanMap, parsedRoot: Ast, parsedSpans: NodeSpanMap, - textEdits: SourceRangeEdit[], + textEdits: SourceRangeEditDesc[], codeAfter: string, ): Map { const newSpans = new Map() @@ -118,7 +117,7 @@ function calculateCorrespondence( for (const [astAfter, partsAfter] of astsMatchingPartsAfter) { for (const partAfter of partsAfter) { const astBefore = partAfterToAstBefore.get(sourceRangeKey(partAfter))! - if (astBefore.typeName() === astAfter.typeName()) { + if (astBefore.typeName === astAfter.typeName) { ;(rangeLength(newSpans.get(astAfter.id)!) === rangeLength(partAfter) ? toSync : candidates @@ -143,7 +142,7 @@ function calculateCorrespondence( const unmatchedNewAsts = newAsts.filter(ast => !newIdsMatched.has(ast.id)) const unmatchedOldAsts = oldHashes.get(hash)?.filter(ast => !oldIdsMatched.has(ast.id)) ?? [] for (const [unmatchedNew, unmatchedOld] of iter.zip(unmatchedNewAsts, unmatchedOldAsts)) { - if (unmatchedNew.typeName() === unmatchedOld.typeName()) { + if (unmatchedNew.typeName === unmatchedOld.typeName) { toSync.set(unmatchedOld.id, unmatchedNew) // Update the matched-IDs indices. oldIdsMatched.add(unmatchedOld.id) @@ -156,13 +155,13 @@ function calculateCorrespondence( // movement-matching. for (const [beforeId, after] of candidates) { if (oldIdsMatched.has(beforeId) || newIdsMatched.has(after.id)) continue - if (after.typeName() === ast.module.get(beforeId).typeName()) { + if (after.typeName === ast.module.get(beforeId).typeName) { toSync.set(beforeId, after) } } for (const [idBefore, astAfter] of toSync.entries()) - assert(ast.module.get(idBefore).typeName() === astAfter.typeName()) + assert(ast.module.get(idBefore).typeName === astAfter.typeName) return toSync } diff --git a/app/ydoc-shared/src/ast/token.ts b/app/ydoc-shared/src/ast/token.ts index 153e09f139f1..6220b7403e6c 100644 --- a/app/ydoc-shared/src/ast/token.ts +++ b/app/ydoc-shared/src/ast/token.ts @@ -7,6 +7,7 @@ import { newExternalId } from './idMap' import type { AstId, DeepReadonly, NodeChild, Owned } from './tree' import { Ast } from './tree' export import TokenType = RawAst.Token.Type +export import tokenTypes = RawAst.Token.typeNames /** Whether the given value is a {@link Token}. */ export function isToken(maybeToken: unknown): maybeToken is Token { @@ -34,15 +35,11 @@ export interface SyncTokenId { /** A structure representing a lexical source code unit in the AST. */ export class Token implements SyncTokenId { - readonly id: TokenId - code_: string - tokenType_: TokenType | undefined - - private constructor(code: string, type: TokenType | undefined, id: TokenId) { - this.id = id - this.code_ = code - this.tokenType_ = type - } + private constructor( + readonly code_: string, + readonly tokenType_: TokenType | undefined, + readonly id: TokenId, + ) {} /** The id of this token. */ get externalId(): TokenId { @@ -71,8 +68,8 @@ export class Token implements SyncTokenId { } /** The name of the token type of this token. */ - typeName(): string { - if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]! + get typeName(): string { + if (this.tokenType_ != null) return RawAst.Token.typeNames[this.tokenType_]! else return 'Raw' } } diff --git a/app/ydoc-shared/src/ast/tree.ts b/app/ydoc-shared/src/ast/tree.ts index fc2068b1ff09..62b8dab1d5e3 100644 --- a/app/ydoc-shared/src/ast/tree.ts +++ b/app/ydoc-shared/src/ast/tree.ts @@ -125,10 +125,33 @@ const nodeMetadataKeys = allKeys({ export type NodeMetadata = FixedMapView export type MutableNodeMetadata = FixedMap +export const astTypes = [ + 'App', + 'Assignment', + 'BodyBlock', + 'ExpressionStatement', + 'FunctionDef', + 'Generic', + 'Group', + 'Ident', + 'Import', + 'Invalid', + 'NegationApp', + 'NumericLiteral', + 'OprApp', + 'PropertyAccess', + 'TextLiteral', + 'UnaryOprApp', + 'AutoscopedIdentifier', + 'Vector', + 'Wildcard', +] as const +export type AstType = (typeof astTypes)[number] + /** @internal */ interface RawAstFields { id: AstId - type: string + type: AstType parent: AstId | undefined metadata: FixedMap } @@ -227,7 +250,7 @@ export abstract class Ast { } /** TODO: Add docs */ - typeName(): string { + get typeName(): AstType { return this.fields.get('type') } diff --git a/app/ydoc-shared/src/util/data/__tests__/text.test.ts b/app/ydoc-shared/src/util/data/__tests__/text.test.ts index 63c02dacd083..7986c5cb2349 100644 --- a/app/ydoc-shared/src/util/data/__tests__/text.test.ts +++ b/app/ydoc-shared/src/util/data/__tests__/text.test.ts @@ -1,6 +1,70 @@ import { fc, test } from '@fast-check/vitest' import { expect } from 'vitest' -import { applyTextEdits, applyTextEditsToSpans, textChangeToEdits, trimEnd } from '../text' +import { + type SourceRange, + applyTextEdits, + applyTextEditsToSpans, + rangeEncloses, + rangeIntersects, + textChangeToEdits, + trimEnd, +} from '../text' + +type RangeTest = { a: SourceRange; b: SourceRange } + +function rangeTest({ a, b }: { a: number[]; b: number[] }) { + return { a: { from: a[0]!, to: a[1]! }, b: { from: b[0]!, to: b[1]! } } +} + +const equalRanges: RangeTest[] = [ + { a: [0, 0], b: [0, 0] }, + { a: [0, 1], b: [0, 1] }, + { a: [-5, 5], b: [-5, 5] }, +].map(rangeTest) + +const totalOverlap: RangeTest[] = [ + { a: [0, 1], b: [0, 0] }, + { a: [0, 2], b: [2, 2] }, + { a: [-1, 1], b: [1, 1] }, + { a: [0, 2], b: [0, 1] }, + { a: [-10, 10], b: [-3, 7] }, + { a: [0, 5], b: [1, 2] }, + { a: [3, 5], b: [3, 4] }, +].map(rangeTest) + +const reverseTotalOverlap: RangeTest[] = totalOverlap.map(({ a, b }) => ({ a: b, b: a })) + +const noOverlap: RangeTest[] = [ + { a: [0, 1], b: [2, 3] }, + { a: [0, 1], b: [-1, -1] }, + { a: [5, 6], b: [2, 3] }, + { a: [0, 2], b: [-2, -1] }, + { a: [-5, -3], b: [9, 10] }, + { a: [-3, 2], b: [3, 4] }, +].map(rangeTest) + +const partialOverlap: RangeTest[] = [ + { a: [0, 3], b: [-1, 1] }, + { a: [0, 1], b: [-1, 0] }, + { a: [0, 0], b: [-1, 0] }, + { a: [0, 2], b: [1, 4] }, + { a: [-8, 0], b: [0, 10] }, +].map(rangeTest) + +test.each([...equalRanges, ...totalOverlap])('Range $a should enclose $b', ({ a, b }) => + expect(rangeEncloses(a, b)).toBe(true), +) +test.each([...noOverlap, ...partialOverlap, ...reverseTotalOverlap])( + 'Range $a should not enclose $b', + ({ a, b }) => expect(rangeEncloses(a, b)).toBe(false), +) +test.each([...equalRanges, ...totalOverlap, ...reverseTotalOverlap, ...partialOverlap])( + 'Range $a should intersect $b', + ({ a, b }) => expect(rangeIntersects(a, b)).toBe(true), +) +test.each([...noOverlap])('Range $a should not intersect $b', ({ a, b }) => + expect(rangeIntersects(a, b)).toBe(false), +) test.prop({ before: fc.array(fc.boolean(), { minLength: 32, maxLength: 64 }), @@ -45,12 +109,12 @@ function checkCorrespondence(a: string[], b: string[]) { Performs the same check as {@link checkCorrespondence}, for correspondences that are not expected to be reversible. */ function checkCorrespondenceForward(before: string[], after: string[]) { - const leadingSpacesAndLength = (input: string): [number, number] => [ - input.lastIndexOf(' ') + 1, - input.length, - ] - const spacesAndHyphens = ([spaces, length]: readonly [number, number]) => { - return ' '.repeat(spaces) + '-'.repeat(length - spaces) + const leadingSpacesAndLength = (input: string): SourceRange => ({ + from: input.lastIndexOf(' ') + 1, + to: input.length, + }) + const spacesAndHyphens = ({ from, to }: SourceRange) => { + return ' '.repeat(from) + '-'.repeat(to - from) } const edits = textChangeToEdits(before[0]!, after[0]!) const spansAfter = applyTextEditsToSpans(edits, before.slice(1).map(leadingSpacesAndLength)).map( diff --git a/app/ydoc-shared/src/util/data/text.ts b/app/ydoc-shared/src/util/data/text.ts index e38464c81d6c..da8d1620e53d 100644 --- a/app/ydoc-shared/src/util/data/text.ts +++ b/app/ydoc-shared/src/util/data/text.ts @@ -1,8 +1,63 @@ import * as iter from 'enso-common/src/utilities/data/iter' import diff from 'fast-diff' -import { rangeEncloses, rangeLength, type SourceRange } from '../../yjsModel' +import { type DeepReadonly } from '../../ast' -export type SourceRangeEdit = { range: SourceRange; insert: string } +export interface SourceRange { + readonly from: number + readonly to: number +} +declare const brandSourceRangeKey: unique symbol +export type SourceRangeKey = string & { [brandSourceRangeKey]: never } + +/** TODO: Add docs */ +export function sourceRangeKey({ from, to }: SourceRange): SourceRangeKey { + return `${from.toString(16)}:${to.toString(16)}` as SourceRangeKey +} +/** TODO: Add docs */ +export function sourceRangeFromKey(key: SourceRangeKey): SourceRange { + const [from, to] = key.split(':').map(x => parseInt(x, 16)) as [number, number] + return { from, to } +} + +/** TODO: Add docs */ +export function rangeEquals(a: SourceRange, b: SourceRange): boolean { + return a.from == b.from && a.to == b.to +} + +/** TODO: Add docs */ +export function rangeIncludes(a: SourceRange, b: number): boolean { + return a.from <= b && a.to >= b +} + +/** TODO: Add docs */ +export function rangeLength(a: SourceRange): number { + return a.to - a.from +} + +/** TODO: Add docs */ +export function rangeEncloses(a: SourceRange, b: SourceRange): boolean { + return a.from <= b.from && a.to >= b.to +} + +/** TODO: Add docs */ +export function rangeIntersects(a: SourceRange, b: SourceRange): boolean { + return a.from <= b.to && a.to >= b.from +} + +/** Whether the given range is before the other range. */ +export function rangeIsBefore(a: SourceRange, b: SourceRange): boolean { + return a.to <= b.from +} + +/** Describes how a change to text will affect document locations. */ +export interface SourceRangeEditDesc extends SourceRange { + insert: { length: number } +} + +/** A change that can be applied to text. */ +export interface SourceRangeEdit extends SourceRangeEditDesc { + insert: string +} /** Given text and a set of `TextEdit`s, return the result of applying the edits to the text. */ export function applyTextEdits( @@ -10,13 +65,13 @@ export function applyTextEdits( textEdits: ReadonlyArray>, ) { const editsOrdered = [...textEdits] - editsOrdered.sort((a, b) => a.range[0] - b.range[0]) + editsOrdered.sort((a, b) => a.from - b.from) let start = 0 let newText = '' for (const textEdit of editsOrdered) { - newText += oldText.slice(start, textEdit.range[0]) + newText += oldText.slice(start, textEdit.from) newText += textEdit.insert - start = textEdit.range[1] + start = textEdit.to } newText += oldText.slice(start) return newText @@ -36,7 +91,7 @@ export function textChangeToEdits(before: string, after: string): SourceRangeEdi for (const [op, text] of diff(before, after)) { switch (op) { case diff.INSERT: - if (!nextEdit) nextEdit = { range: [pos, pos], insert: '' } + if (!nextEdit) nextEdit = { from: pos, to: pos, insert: '' } nextEdit.insert = text break case diff.EQUAL: @@ -49,7 +104,7 @@ export function textChangeToEdits(before: string, after: string): SourceRangeEdi case diff.DELETE: { if (nextEdit) textEdits.push(nextEdit) const endPos = pos + text.length - nextEdit = { range: [pos, endPos], insert: '' } + nextEdit = { from: pos, to: endPos, insert: '' } pos = endPos break } @@ -59,9 +114,9 @@ export function textChangeToEdits(before: string, after: string): SourceRangeEdi return textEdits } -/** Translate a `TextEdit` by the specified offset. */ -export function offsetEdit(textEdit: SourceRangeEdit, offset: number): SourceRangeEdit { - return { ...textEdit, range: [textEdit.range[0] + offset, textEdit.range[1] + offset] } +/** Translate a source range by the specified offset. */ +export function translateRange(textEdit: T, offset: number): T { + return { ...textEdit, from: textEdit.from + offset, to: textEdit.to + offset } } /** @@ -71,38 +126,42 @@ export function offsetEdit(textEdit: SourceRangeEdit, offset: number): SourceRan * @returns - A sequence of: Each span from `spansBefore` paired with the smallest span of the text after the edit that * contains all text that was in the original span and has not been deleted. */ -export function applyTextEditsToSpans(textEdits: SourceRangeEdit[], spansBefore: SourceRange[]) { +export function applyTextEditsToSpans( + textEdits: DeepReadonly, + spansBefore: DeepReadonly, +) { // Gather start and end points. const numerically = (a: number, b: number) => a - b - const starts = new iter.Resumable(spansBefore.map(([start, _end]) => start).sort(numerically)) - const ends = new iter.Resumable(spansBefore.map(([_start, end]) => end).sort(numerically)) + const starts = new iter.Resumable(spansBefore.map(({ from }) => from).sort(numerically)) + const ends = new iter.Resumable(spansBefore.map(({ to }) => to).sort(numerically)) // Construct translations from old locations to new locations for all start and end points. const startMap = new Map() const endMap = new Map() let offset = 0 - for (const { range, insert } of textEdits) { + for (const textEdit of textEdits) { + const { from, to, insert } = textEdit starts.advanceWhile(start => { - if (start < range[0]) { + if (start < from) { startMap.set(start, start + offset) return true - } else if (start <= range[1]) { - startMap.set(start, range[0] + offset + insert.length) + } else if (start <= to) { + startMap.set(start, from + offset + insert.length) return true } return false }) ends.advanceWhile(end => { - if (end <= range[0]) { + if (end <= from) { endMap.set(end, end + offset) return true - } else if (end <= range[1]) { - endMap.set(end, range[0] + offset) + } else if (end <= to) { + endMap.set(end, from + offset) return true } return false }) - offset += insert.length - rangeLength(range) + offset += insert.length - rangeLength(textEdit) } starts.forEach(start => startMap.set(start, start + offset)) ends.forEach(end => endMap.set(end, end + offset)) @@ -110,9 +169,10 @@ export function applyTextEditsToSpans(textEdits: SourceRangeEdit[], spansBefore: // Apply the translations to the map. const spansBeforeAndAfter = new Array() for (const spanBefore of spansBefore) { - const startAfter = startMap.get(spanBefore[0])! - const endAfter = endMap.get(spanBefore[1])! - if (endAfter > startAfter) spansBeforeAndAfter.push([spanBefore, [startAfter, endAfter]]) + const startAfter = startMap.get(spanBefore.from)! + const endAfter = endMap.get(spanBefore.to)! + if (endAfter > startAfter) + spansBeforeAndAfter.push([spanBefore, { from: startAfter, to: endAfter }]) } return spansBeforeAndAfter } @@ -150,7 +210,9 @@ export function enclosingSpans( } /** Return the given range with any trailing spaces stripped. */ -export function trimEnd(range: SourceRange, text: string): SourceRange { - const trimmedLength = text.slice(range[0], range[1]).search(/ +$/) - return trimmedLength === -1 ? range : [range[0], range[0] + trimmedLength] +export function trimEnd(range: T, text: string): T { + const trimmedLength = text.slice(range.from, range.to).search(/ +$/) + return trimmedLength === -1 ? range : ( + { ...range, from: range.from, to: range.from + trimmedLength } + ) } diff --git a/app/ydoc-shared/src/util/lezer.ts b/app/ydoc-shared/src/util/lezer.ts index e12ef4abb1c6..da222c76bbd7 100644 --- a/app/ydoc-shared/src/util/lezer.ts +++ b/app/ydoc-shared/src/util/lezer.ts @@ -1,4 +1,4 @@ -import type { TreeCursor } from '@lezer/common' +import { type SyntaxNode, type TreeCursor } from '@lezer/common' /** * Compares the value of `cursor.name` to the provided value. This can be used instead of reading the field directly to @@ -8,3 +8,12 @@ import type { TreeCursor } from '@lezer/common' export function isNodeType(cursor: TreeCursor, type: string): boolean { return cursor.name === type } + +/** Yields the provided node, and its parents recursively. */ +export function* syntaxNodeAncestors(syn: SyntaxNode | null) { + let currentSyn: SyntaxNode | null = syn + while (currentSyn != null) { + yield currentSyn + currentSyn = currentSyn.parent + } +} diff --git a/app/ydoc-shared/src/yjsModel.ts b/app/ydoc-shared/src/yjsModel.ts index 69532fcd2135..80b911c5a714 100644 --- a/app/ydoc-shared/src/yjsModel.ts +++ b/app/ydoc-shared/src/yjsModel.ts @@ -1,6 +1,7 @@ import * as object from 'lib0/object' import * as random from 'lib0/random' import * as Y from 'yjs' +import { type SourceRange, type SourceRangeKey, sourceRangeKey } from './util/data/text' export type Uuid = `${string}-${string}-${string}-${string}-${string}` @@ -166,19 +167,6 @@ export function tryAsOrigin(origin: string): Origin | undefined { if (origin === 'remote') return origin } -export type SourceRange = readonly [start: number, end: number] -declare const brandSourceRangeKey: unique symbol -export type SourceRangeKey = string & { [brandSourceRangeKey]: never } - -/** TODO: Add docs */ -export function sourceRangeKey(range: SourceRange): SourceRangeKey { - return `${range[0].toString(16)}:${range[1].toString(16)}` as SourceRangeKey -} -/** TODO: Add docs */ -export function sourceRangeFromKey(key: SourceRangeKey): SourceRange { - return key.split(':').map(x => parseInt(x, 16)) as [number, number] -} - /** TODO: Add docs */ export class IdMap { private readonly rangeToExpr: Map @@ -278,33 +266,3 @@ const uuidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/ export function isUuid(x: unknown): x is Uuid { return typeof x === 'string' && x.length === 36 && uuidRegex.test(x) } - -/** TODO: Add docs */ -export function rangeEquals(a: SourceRange, b: SourceRange): boolean { - return a[0] == b[0] && a[1] == b[1] -} - -/** TODO: Add docs */ -export function rangeIncludes(a: SourceRange, b: number): boolean { - return a[0] <= b && a[1] >= b -} - -/** TODO: Add docs */ -export function rangeLength(a: SourceRange): number { - return a[1] - a[0] -} - -/** TODO: Add docs */ -export function rangeEncloses(a: SourceRange, b: SourceRange): boolean { - return a[0] <= b[0] && a[1] >= b[1] -} - -/** TODO: Add docs */ -export function rangeIntersects(a: SourceRange, b: SourceRange): boolean { - return a[0] <= b[1] && a[1] >= b[0] -} - -/** Whether the given range is before the other range. */ -export function rangeIsBefore(a: SourceRange, b: SourceRange): boolean { - return a[1] <= b[0] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972b3378b195..cd8885031bbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,26 +125,26 @@ importers: specifier: ^7.24.7 version: 7.25.6 '@codemirror/commands': - specifier: ^6.6.0 - version: 6.6.0 + specifier: ^6.7.1 + version: 6.7.1 '@codemirror/lang-markdown': specifier: ^v6.3.0 version: 6.3.0 '@codemirror/language': - specifier: ^6.10.2 - version: 6.10.2 + specifier: ^6.10.6 + version: 6.10.6 '@codemirror/lint': - specifier: ^6.8.1 - version: 6.8.1 + specifier: ^6.8.4 + version: 6.8.4 '@codemirror/search': - specifier: ^6.5.6 - version: 6.5.6 + specifier: ^6.5.8 + version: 6.5.8 '@codemirror/state': - specifier: ^6.4.1 - version: 6.4.1 + specifier: ^6.5.0 + version: 6.5.0 '@codemirror/view': - specifier: ^6.35.0 - version: 6.35.0 + specifier: ^6.35.3 + version: 6.35.3 '@fast-check/vitest': specifier: ^0.0.8 version: 0.0.8(vitest@1.6.0(@types/node@22.9.0)(jsdom@24.1.0)(lightningcss@1.25.1)) @@ -1410,8 +1410,8 @@ packages: '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 - '@codemirror/commands@6.6.0': - resolution: {integrity: sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==} + '@codemirror/commands@6.7.1': + resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} '@codemirror/lang-css@6.3.0': resolution: {integrity: sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==} @@ -1428,23 +1428,23 @@ packages: '@codemirror/lang-markdown@6.3.0': resolution: {integrity: sha512-lYrI8SdL/vhd0w0aHIEvIRLRecLF7MiiRfzXFZY94dFwHqC9HtgxgagJ8fyYNBldijGatf9wkms60d8SrAj6Nw==} - '@codemirror/language@6.10.2': - resolution: {integrity: sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==} + '@codemirror/language@6.10.6': + resolution: {integrity: sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==} - '@codemirror/lint@6.8.1': - resolution: {integrity: sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==} + '@codemirror/lint@6.8.4': + resolution: {integrity: sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==} - '@codemirror/search@6.5.6': - resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==} + '@codemirror/search@6.5.8': + resolution: {integrity: sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==} - '@codemirror/state@6.4.1': - resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} + '@codemirror/state@6.5.0': + resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==} '@codemirror/theme-one-dark@6.1.2': resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==} - '@codemirror/view@6.35.0': - resolution: {integrity: sha512-I0tYy63q5XkaWsJ8QRv5h6ves7kvtrBWjBcnf/bzohFJQc5c14a1AQRdE8QpPF9eMp5Mq2FMm59TCj1gDfE7kw==} + '@codemirror/view@6.35.3': + resolution: {integrity: sha512-ScY7L8+EGdPl4QtoBiOzE4FELp7JmNUsBvgBcCakXWM2uiv/K89VAzU3BMDscf0DsACLvTKePbd5+cFDTcei6g==} '@csstools/selector-resolve-nested@1.1.0': resolution: {integrity: sha512-uWvSaeRcHyeNenKg8tp17EVDRkpflmdyvbE0DHo6D/GdBb6PDnCYYU6gRpXhtICMGMcahQmj2zGxwFM/WC8hCg==} @@ -2036,6 +2036,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mdx-js/react@3.1.0': resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} peerDependencies: @@ -8972,25 +8975,25 @@ snapshots: - '@chromatic-com/playwright' - react - '@codemirror/autocomplete@6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1)': + '@codemirror/autocomplete@6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1)': dependencies: - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 - '@codemirror/commands@6.6.0': + '@codemirror/commands@6.7.1': dependencies: - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 - '@codemirror/lang-css@6.3.0(@codemirror/view@6.35.0)': + '@codemirror/lang-css@6.3.0(@codemirror/view@6.35.3)': dependencies: - '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 + '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1) + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 '@lezer/common': 1.2.1 '@lezer/css': 1.1.9 transitivePeerDependencies: @@ -8998,74 +9001,76 @@ snapshots: '@codemirror/lang-html@6.4.9': dependencies: - '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1) - '@codemirror/lang-css': 6.3.0(@codemirror/view@6.35.0) + '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1) + '@codemirror/lang-css': 6.3.0(@codemirror/view@6.35.3) '@codemirror/lang-javascript': 6.2.2 - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 '@lezer/css': 1.1.9 '@lezer/html': 1.3.10 '@codemirror/lang-javascript@6.2.2': dependencies: - '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.2 - '@codemirror/lint': 6.8.1 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1) + '@codemirror/language': 6.10.6 + '@codemirror/lint': 6.8.4 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 '@lezer/javascript': 1.4.18 '@codemirror/lang-json@6.0.1': dependencies: - '@codemirror/language': 6.10.2 + '@codemirror/language': 6.10.6 '@lezer/json': 1.0.2 '@codemirror/lang-markdown@6.3.0': dependencies: - '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1) + '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1) '@codemirror/lang-html': 6.4.9 - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 '@lezer/markdown': 1.3.1 - '@codemirror/language@6.10.2': + '@codemirror/language@6.10.6': dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/common': 1.2.1 '@lezer/highlight': 1.2.0 '@lezer/lr': 1.4.1 style-mod: 4.1.2 - '@codemirror/lint@6.8.1': + '@codemirror/lint@6.8.4': dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 crelt: 1.0.6 - '@codemirror/search@6.5.6': + '@codemirror/search@6.5.8': dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 crelt: 1.0.6 - '@codemirror/state@6.4.1': {} + '@codemirror/state@6.5.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 '@codemirror/theme-one-dark@6.1.2': dependencies: - '@codemirror/language': 6.10.2 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/language': 6.10.6 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 '@lezer/highlight': 1.2.0 - '@codemirror/view@6.35.0': + '@codemirror/view@6.35.3': dependencies: - '@codemirror/state': 6.4.1 + '@codemirror/state': 6.5.0 style-mod: 4.1.2 w3c-keyname: 2.2.8 @@ -9409,13 +9414,13 @@ snapshots: '@histoire/controls@0.17.17(vite@5.4.10(@types/node@22.9.0)(lightningcss@1.25.1))': dependencies: - '@codemirror/commands': 6.6.0 + '@codemirror/commands': 6.7.1 '@codemirror/lang-json': 6.0.1 - '@codemirror/language': 6.10.2 - '@codemirror/lint': 6.8.1 - '@codemirror/state': 6.4.1 + '@codemirror/language': 6.10.6 + '@codemirror/lint': 6.8.4 + '@codemirror/state': 6.5.0 '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.35.0 + '@codemirror/view': 6.35.3 '@histoire/shared': 0.17.17(vite@5.4.10(@types/node@22.9.0)(lightningcss@1.25.1)) '@histoire/vendors': 0.17.17 transitivePeerDependencies: @@ -9675,6 +9680,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.2': {} + '@mdx-js/react@3.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 @@ -12554,13 +12561,13 @@ snapshots: codemirror@6.0.1(@lezer/common@1.2.1): dependencies: - '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)(@lezer/common@1.2.1) - '@codemirror/commands': 6.6.0 - '@codemirror/language': 6.10.2 - '@codemirror/lint': 6.8.1 - '@codemirror/search': 6.5.6 - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.35.0 + '@codemirror/autocomplete': 6.16.3(@codemirror/language@6.10.6)(@codemirror/state@6.5.0)(@codemirror/view@6.35.3)(@lezer/common@1.2.1) + '@codemirror/commands': 6.7.1 + '@codemirror/language': 6.10.6 + '@codemirror/lint': 6.8.4 + '@codemirror/search': 6.5.8 + '@codemirror/state': 6.5.0 + '@codemirror/view': 6.35.3 transitivePeerDependencies: - '@lezer/common'