From 3b12b6e17bc9035b9025e8a51bb1fb08791b0eb4 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Tue, 7 Nov 2023 18:07:44 +0100 Subject: [PATCH] Refactor `Input` class to be composable (#8244) Part of #7926 I found myself wanting to use graph store in the `Input` class. As I learned about composables recently, I decided to refactor the class into a composable, to be more vue-like. Putting it in a separate PR because [other task may wanting to do the same](8066) Also contains some preparations for my actual implementation. --- app/gui2/src/components/ComponentBrowser.vue | 7 +- .../ComponentBrowser/__tests__/input.test.ts | 30 ++- .../src/components/ComponentBrowser/input.ts | 198 ++++++++++-------- .../src/stores/suggestionDatabase/entry.ts | 4 +- 4 files changed, 137 insertions(+), 102 deletions(-) diff --git a/app/gui2/src/components/ComponentBrowser.vue b/app/gui2/src/components/ComponentBrowser.vue index 8f1610052b..e58c4956bf 100644 --- a/app/gui2/src/components/ComponentBrowser.vue +++ b/app/gui2/src/components/ComponentBrowser.vue @@ -2,7 +2,6 @@ import { componentBrowserBindings } from '@/bindings' import { makeComponentList, type Component } from '@/components/ComponentBrowser/component' import { Filtering } from '@/components/ComponentBrowser/filtering' -import { Input } from '@/components/ComponentBrowser/input' import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue' import SvgIcon from '@/components/SvgIcon.vue' import ToggleIcon from '@/components/ToggleIcon.vue' @@ -18,6 +17,7 @@ import { Vec2 } from '@/util/vec2' import type { SuggestionId } from 'shared/languageServerTypes/suggestions' import type { ContentRange } from 'shared/yjsModel.ts' import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue' +import { useComponentBrowserInput } from './ComponentBrowser/input' const ITEM_SIZE = 32 const TOP_BAR_HEIGHT = 32 @@ -46,6 +46,7 @@ onMounted(() => { }) const projectStore = useProjectStore() +const suggestionDbStore = useSuggestionDbStore() // === Position === @@ -63,7 +64,7 @@ const transform = computed(() => { const cbRoot = ref() const inputField = ref() -const input = new Input() +const input = useComponentBrowserInput() const filterFlags = ref({ showUnstable: false, showLocal: false }) const currentFiltering = computed(() => { @@ -128,8 +129,6 @@ function handleDefocus(e: FocusEvent) { // === Components List and Positions === -const suggestionDbStore = useSuggestionDbStore() - const components = computed(() => { return makeComponentList(suggestionDbStore.entries, currentFiltering.value) }) diff --git a/app/gui2/src/components/ComponentBrowser/__tests__/input.test.ts b/app/gui2/src/components/ComponentBrowser/__tests__/input.test.ts index 601e9b22fb..c0d1f8c05d 100644 --- a/app/gui2/src/components/ComponentBrowser/__tests__/input.test.ts +++ b/app/gui2/src/components/ComponentBrowser/__tests__/input.test.ts @@ -9,7 +9,7 @@ import { } from '@/stores/suggestionDatabase/entry' import { readAstSpan } from '@/util/ast' import { expect, test } from 'vitest' -import { Input } from '../input' +import { useComponentBrowserInput } from '../input' test.each([ ['', 0, { type: 'insert', position: 0 }, {}], @@ -40,6 +40,20 @@ test.each([ ], ['2 +', 3, { type: 'insert', position: 3, oprApp: ['2', '+', null] }, {}], ['2 + 3', 5, { type: 'changeLiteral', literal: '3' }, { pattern: '3' }], + // TODO[ao] test cases for #7926 + // [ + // 'operator1.', + // 10, + // { type: 'insert', position: 10, oprApp: ['operator1', '.', null] }, + // { selfType: 'Standard.Base.Number' }, + // ], + // [ + // 'operator2.', + // 10, + // { type: 'insert', position: 10, oprApp: ['operator2', '.', null] }, + // // No self type, as the operator2 local is from another module + // { qualifiedNamePattern: 'operator2' }, + // ], ])( "Input context and filtering, when content is '%s' and cursor at %i", ( @@ -52,9 +66,16 @@ test.each([ identifier?: string literal?: string }, - expFiltering: { pattern?: string; qualifiedNamePattern?: string }, + expFiltering: { pattern?: string; qualifiedNamePattern?: string; selfType?: string }, ) => { - const input = new Input() + // TODO[ao] See above commented cases for #7926 + // const db = SuggestionDb.mock([ + // makeLocal('local.Project', 'operator1', 'Standard.Base.Number'), + // makeLocal('local.Project.Another_Module', 'operator2', 'Standard.Base.Text'), + // makeType('local.Project.operator1'), + // makeLocal('local.Project', 'operator3', 'Standard.Base.Text'), + // ]) + const input = useComponentBrowserInput() input.code.value = code input.selection.value = { start: cursorPos, end: cursorPos } const context = input.context.value @@ -78,6 +99,7 @@ test.each([ } expect(filter.pattern).toStrictEqual(expFiltering.pattern) expect(filter.qualifiedNamePattern).toStrictEqual(expFiltering.qualifiedNamePattern) + expect(filter.selfType).toStrictEqual(expFiltering.selfType) }, ) @@ -195,7 +217,7 @@ test.each([ ({ code, cursorPos, suggestion, expected, expectedCursorPos }) => { cursorPos = cursorPos ?? code.length expectedCursorPos = expectedCursorPos ?? expected.length - const input = new Input() + const input = useComponentBrowserInput() input.code.value = code input.selection.value = { start: cursorPos, end: cursorPos } input.applySuggestion(suggestion) diff --git a/app/gui2/src/components/ComponentBrowser/input.ts b/app/gui2/src/components/ComponentBrowser/input.ts index 18966411b0..cfd5fc3d8d 100644 --- a/app/gui2/src/components/ComponentBrowser/input.ts +++ b/app/gui2/src/components/ComponentBrowser/input.ts @@ -13,10 +13,11 @@ import { qnLastSegment, qnParent, qnSplit, + tryIdentifier, tryQualifiedName, type QualifiedName, } from '@/util/qualifiedName' -import { computed, ref, type ComputedRef, type Ref } from 'vue' +import { computed, ref, type ComputedRef } from 'vue' /** Input's editing context. * @@ -43,79 +44,70 @@ export type EditingContext = | { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number } /** Component Browser Input Data */ -export class Input { - /** The current input's text (code). */ - readonly code: Ref - /** The current selection (or cursor position if start is equal to end). */ - readonly selection: Ref<{ start: number; end: number }> - /** The editing context deduced from code and selection */ - readonly context: ComputedRef - /** The filter deduced from code and selection. */ - readonly filter: ComputedRef +export function useComponentBrowserInput() { + const code = ref('') + const selection = ref({ start: 0, end: 0 }) - constructor() { - this.code = ref('') - this.selection = ref({ start: 0, end: 0 }) + const context: ComputedRef = computed(() => { + const input = code.value + const cursorPosition = selection.value.start + if (cursorPosition === 0) return { type: 'insert', position: 0 } + const editedPart = cursorPosition - 1 + const inputAst = parseEnso(input) + const editedAst = astContainingChar(editedPart, inputAst).values() + const leaf = editedAst.next() + if (leaf.done) return { type: 'insert', position: cursorPosition } + switch (leaf.value.type) { + case Ast.Tree.Type.Ident: + return { + type: 'changeIdentifier', + identifier: leaf.value, + ...readOprApp(editedAst.next(), leaf.value), + } + case Ast.Tree.Type.TextLiteral: + case Ast.Tree.Type.Number: + return { type: 'changeLiteral', literal: leaf.value } + default: + return { + type: 'insert', + position: cursorPosition, + ...readOprApp(leaf), + } + } + }) - this.context = computed(() => { - const input = this.code.value - const cursorPosition = this.selection.value.start - if (cursorPosition === 0) return { type: 'insert', position: 0 } - const editedPart = cursorPosition - 1 - const inputAst = parseEnso(input) - const editedAst = astContainingChar(editedPart, inputAst).values() - const leaf = editedAst.next() - if (leaf.done) return { type: 'insert', position: cursorPosition } - switch (leaf.value.type) { - case Ast.Tree.Type.Ident: - return { - type: 'changeIdentifier', - identifier: leaf.value, - ...Input.readOprApp(editedAst.next(), input, leaf.value), - } - case Ast.Tree.Type.TextLiteral: - case Ast.Tree.Type.Number: - return { type: 'changeLiteral', literal: leaf.value } - default: - return { - type: 'insert', - position: cursorPosition, - ...Input.readOprApp(leaf, input), - } - } - }) + // Filter deduced from the access (`.` operator) chain written by user. + const accessChainFilter: ComputedRef = computed(() => { + const ctx = context.value + if (ctx.type === 'changeLiteral') return {} + if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {} + const opr = ctx.oprApp.lastOpr() + const input = code.value + if (opr == null || !opr.ok || readTokenSpan(opr.value, input) !== '.') return {} + const selfType = pathAsSelfType(ctx.oprApp, input) + if (selfType != null) return { selfType } + const qn = pathAsQualifiedName(ctx.oprApp, input) + if (qn != null) return { qualifiedNamePattern: qn } + return {} + }) - const qualifiedNameFilter: ComputedRef = computed(() => { - const code = this.code.value - const ctx = this.context.value - if (ctx.type === 'changeLiteral') return {} - if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {} - const opr = ctx.oprApp.lastOpr() - if (opr == null || !opr.ok || readTokenSpan(opr.value, code) !== '.') return {} - const qn = Input.pathAsQualifiedName(ctx.oprApp, code) - if (qn != null) return { qualifiedNamePattern: qn } - else return {} - }) + const filter = computed(() => { + const input = code.value + const ctx = context.value + const filter = { ...accessChainFilter.value } + if (ctx.type === 'changeIdentifier') { + const start = + ctx.identifier.whitespaceStartInCodeParsed + ctx.identifier.whitespaceLengthInCodeParsed + const end = selection.value.end + filter.pattern = input.substring(start, end) + } else if (ctx.type === 'changeLiteral') { + filter.pattern = readAstSpan(ctx.literal, input) + } + return filter + }) - this.filter = computed(() => { - const code = this.code.value - const ctx = this.context.value - const filter = { ...qualifiedNameFilter.value } - if (ctx.type === 'changeIdentifier') { - const start = - ctx.identifier.whitespaceStartInCodeParsed + ctx.identifier.whitespaceLengthInCodeParsed - const end = this.selection.value.end - filter.pattern = code.substring(start, end) - } else if (ctx.type === 'changeLiteral') { - filter.pattern = readAstSpan(ctx.literal, code) - } - return filter - }) - } - - private static readOprApp( + function readOprApp( leafParent: IteratorResult, - code: string, editedAst?: Ast.Tree, ): { oprApp?: GeneralOprApp @@ -140,9 +132,18 @@ export class Input { } } - private static pathAsQualifiedName(accessOpr: GeneralOprApp, code: string): QualifiedName | null { - const operandsAsIdents = Input.qnIdentifiers(accessOpr, code) - const segments = operandsAsIdents.map((ident) => readAstSpan(ident, code)) + function pathAsSelfType(accessOpr: GeneralOprApp, inputCode: string): QualifiedName | null { + if (accessOpr.lhs == null) return null + if (accessOpr.lhs.type !== Ast.Tree.Type.Ident) return null + if (accessOpr.apps.length > 1) return null + const _ident = tryIdentifier(readAstSpan(accessOpr.lhs, inputCode)) + // TODO[ao]: #7926 add implementation here + return null + } + + function pathAsQualifiedName(accessOpr: GeneralOprApp, inputCode: string): QualifiedName | null { + const operandsAsIdents = qnIdentifiers(accessOpr, inputCode) + const segments = operandsAsIdents.map((ident) => readAstSpan(ident, inputCode)) const rawQn = segments.join('.') const qn = tryQualifiedName(rawQn) return qn.ok ? qn.value : null @@ -155,18 +156,20 @@ export class Input { * @param code The code from which `opr` was generated. * @returns If all path segments are identifiers, return them */ - private static qnIdentifiers(opr: GeneralOprApp, code: string): Ast.Tree.Ident[] { - const operandsAsIdents = Array.from(opr.operandsOfLeftAssocOprChain(code, '.'), (operand) => - operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null, + function qnIdentifiers(opr: GeneralOprApp, inputCode: string): Ast.Tree.Ident[] { + const operandsAsIdents = Array.from( + opr.operandsOfLeftAssocOprChain(inputCode, '.'), + (operand) => + operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null, ).slice(0, -1) if (operandsAsIdents.some((optIdent) => optIdent == null)) return [] else return operandsAsIdents as Ast.Tree.Ident[] } /** Apply given suggested entry to the input. */ - applySuggestion(entry: SuggestionEntry) { - const oldCode = this.code.value - const changes = Array.from(this.inputChangesAfterApplying(entry)).reverse() + function applySuggestion(entry: SuggestionEntry) { + const oldCode = code.value + const changes = Array.from(inputChangesAfterApplying(entry)).reverse() const newCodeUpToLastChange = changes.reduce( (builder, change) => { const oldCodeFragment = oldCode.substring(builder.oldCodeIndex, change.range[0]) @@ -183,11 +186,11 @@ export class Input { !isModule && (firstCharAfter == null || /^[a-zA-Z0-9_]$/.test(firstCharAfter)) const shouldMoveCursor = !isModule const newCursorPos = newCodeUpToLastChange.code.length + (shouldMoveCursor ? 1 : 0) - this.code.value = + code.value = newCodeUpToLastChange.code + (shouldInsertSpace ? ' ' : '') + oldCode.substring(newCodeUpToLastChange.oldCodeIndex) - this.selection.value = { start: newCursorPos, end: newCursorPos } + selection.value = { start: newCursorPos, end: newCursorPos } } /** Return all input changes resulting from applying given suggestion. @@ -195,11 +198,11 @@ export class Input { * @returns The changes, starting from the rightmost. The `start` and `end` parameters refer * to indices of "old" input content. */ - private *inputChangesAfterApplying( + function* inputChangesAfterApplying( entry: SuggestionEntry, ): Generator<{ range: [number, number]; str: string }> { - const ctx = this.context.value - const str = this.codeToBeInserted(entry) + const ctx = context.value + const str = codeToBeInserted(entry) switch (ctx.type) { case 'insert': { yield { range: [ctx.position, ctx.position], str } @@ -214,18 +217,18 @@ export class Input { break } } - yield* this.qnChangesAfterApplying(entry) + yield* qnChangesAfterApplying(entry) } - private codeToBeInserted(entry: SuggestionEntry): string { - const ctx = this.context.value + function codeToBeInserted(entry: SuggestionEntry): string { + const ctx = context.value const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null const oprAppSpacing = ctx.type === 'insert' && opr != null && opr.ok && opr.value.whitespaceLengthInCodeBuffer > 0 ? ' '.repeat(opr.value.whitespaceLengthInCodeBuffer) : '' const extendingAccessOprChain = - opr != null && opr.ok && readTokenSpan(opr.value, this.code.value) === '.' + opr != null && opr.ok && readTokenSpan(opr.value, code.value) === '.' // Modules are special case, as we want to encourage user to continue writing path. if (entry.kind === SuggestionKind.Module) { if (extendingAccessOprChain) return `${oprAppSpacing}${entry.name}${oprAppSpacing}.` @@ -245,14 +248,14 @@ export class Input { /** All changes to the qualified name already written by the user. * * See `inputChangesAfterApplying`. */ - private *qnChangesAfterApplying( + function* qnChangesAfterApplying( entry: SuggestionEntry, ): Generator<{ range: [number, number]; str: string }> { if (entry.selfType != null) return [] if (entry.kind === SuggestionKind.Local || entry.kind === SuggestionKind.Function) return [] - if (this.context.value.type === 'changeLiteral') return [] - if (this.context.value.oprApp == null) return [] - const writtenQn = Input.qnIdentifiers(this.context.value.oprApp, this.code.value).reverse() + if (context.value.type === 'changeLiteral') return [] + if (context.value.oprApp == null) return [] + const writtenQn = qnIdentifiers(context.value.oprApp, code.value).reverse() let containingQn = entry.kind === SuggestionKind.Module @@ -265,4 +268,17 @@ export class Input { containingQn = parent } } + + return { + /** The current input's text (code). */ + code, + /** The current selection (or cursor position if start is equal to end). */ + selection, + /** The editing context deduced from code and selection */ + context, + /** The filter deduced from code and selection. */ + filter, + /** Apply given suggested entry to the input. */ + applySuggestion, + } } diff --git a/app/gui2/src/stores/suggestionDatabase/entry.ts b/app/gui2/src/stores/suggestionDatabase/entry.ts index e3a6d95f0d..8bdde45786 100644 --- a/app/gui2/src/stores/suggestionDatabase/entry.ts +++ b/app/gui2/src/stores/suggestionDatabase/entry.ts @@ -8,11 +8,9 @@ import { qnLastSegment, qnParent, qnSplit, - tryQualifiedName, type Identifier, type QualifiedName, } from '@/util/qualifiedName' -import { unwrap } from '@/util/result' import type { SuggestionEntryArgument, SuggestionEntryScope, @@ -77,7 +75,7 @@ export function entryQn(entry: SuggestionEntry): QualifiedName { } else if (entry.memberOf) { return qnJoin(entry.memberOf, entry.name) } else { - return qnJoin(entry.definedIn, unwrap(tryQualifiedName(entry.name))) + return qnJoin(entry.definedIn, entry.name) } }