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.
This commit is contained in:
Adam Obuchowicz 2023-11-07 18:07:44 +01:00 committed by GitHub
parent 579d83a450
commit 3b12b6e17b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 102 deletions

View File

@ -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<HTMLElement>()
const inputField = ref<HTMLInputElement>()
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)
})

View File

@ -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)

View File

@ -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,23 +44,13 @@ 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<string>
/** 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<EditingContext>
/** The filter deduced from code and selection. */
readonly filter: ComputedRef<Filter>
export function useComponentBrowserInput() {
const code = ref('')
const selection = ref({ start: 0, end: 0 })
constructor() {
this.code = ref('')
this.selection = ref({ start: 0, end: 0 })
this.context = computed(() => {
const input = this.code.value
const cursorPosition = this.selection.value.start
const context: ComputedRef<EditingContext> = 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)
@ -71,7 +62,7 @@ export class Input {
return {
type: 'changeIdentifier',
identifier: leaf.value,
...Input.readOprApp(editedAst.next(), input, leaf.value),
...readOprApp(editedAst.next(), leaf.value),
}
case Ast.Tree.Type.TextLiteral:
case Ast.Tree.Type.Number:
@ -80,42 +71,43 @@ export class Input {
return {
type: 'insert',
position: cursorPosition,
...Input.readOprApp(leaf, input),
...readOprApp(leaf),
}
}
})
const qualifiedNameFilter: ComputedRef<Filter> = computed(() => {
const code = this.code.value
const ctx = this.context.value
// Filter deduced from the access (`.` operator) chain written by user.
const accessChainFilter: ComputedRef<Filter> = computed(() => {
const ctx = 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)
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 }
else return {}
return {}
})
this.filter = computed(() => {
const code = this.code.value
const ctx = this.context.value
const filter = { ...qualifiedNameFilter.value }
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 = this.selection.value.end
filter.pattern = code.substring(start, end)
const end = selection.value.end
filter.pattern = input.substring(start, end)
} else if (ctx.type === 'changeLiteral') {
filter.pattern = readAstSpan(ctx.literal, code)
filter.pattern = readAstSpan(ctx.literal, input)
}
return filter
})
}
private static readOprApp(
function readOprApp(
leafParent: IteratorResult<Ast.Tree>,
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,8 +156,10 @@ 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) =>
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 []
@ -164,9 +167,9 @@ export class Input {
}
/** 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,
}
}

View File

@ -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)
}
}