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 { componentBrowserBindings } from '@/bindings'
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component' import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { Filtering } from '@/components/ComponentBrowser/filtering' import { Filtering } from '@/components/ComponentBrowser/filtering'
import { Input } from '@/components/ComponentBrowser/input'
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue' import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.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 { SuggestionId } from 'shared/languageServerTypes/suggestions'
import type { ContentRange } from 'shared/yjsModel.ts' import type { ContentRange } from 'shared/yjsModel.ts'
import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue' import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
import { useComponentBrowserInput } from './ComponentBrowser/input'
const ITEM_SIZE = 32 const ITEM_SIZE = 32
const TOP_BAR_HEIGHT = 32 const TOP_BAR_HEIGHT = 32
@ -46,6 +46,7 @@ onMounted(() => {
}) })
const projectStore = useProjectStore() const projectStore = useProjectStore()
const suggestionDbStore = useSuggestionDbStore()
// === Position === // === Position ===
@ -63,7 +64,7 @@ const transform = computed(() => {
const cbRoot = ref<HTMLElement>() const cbRoot = ref<HTMLElement>()
const inputField = ref<HTMLInputElement>() const inputField = ref<HTMLInputElement>()
const input = new Input() const input = useComponentBrowserInput()
const filterFlags = ref({ showUnstable: false, showLocal: false }) const filterFlags = ref({ showUnstable: false, showLocal: false })
const currentFiltering = computed(() => { const currentFiltering = computed(() => {
@ -128,8 +129,6 @@ function handleDefocus(e: FocusEvent) {
// === Components List and Positions === // === Components List and Positions ===
const suggestionDbStore = useSuggestionDbStore()
const components = computed(() => { const components = computed(() => {
return makeComponentList(suggestionDbStore.entries, currentFiltering.value) return makeComponentList(suggestionDbStore.entries, currentFiltering.value)
}) })

View File

@ -9,7 +9,7 @@ import {
} from '@/stores/suggestionDatabase/entry' } from '@/stores/suggestionDatabase/entry'
import { readAstSpan } from '@/util/ast' import { readAstSpan } from '@/util/ast'
import { expect, test } from 'vitest' import { expect, test } from 'vitest'
import { Input } from '../input' import { useComponentBrowserInput } from '../input'
test.each([ test.each([
['', 0, { type: 'insert', position: 0 }, {}], ['', 0, { type: 'insert', position: 0 }, {}],
@ -40,6 +40,20 @@ test.each([
], ],
['2 +', 3, { type: 'insert', position: 3, oprApp: ['2', '+', null] }, {}], ['2 +', 3, { type: 'insert', position: 3, oprApp: ['2', '+', null] }, {}],
['2 + 3', 5, { type: 'changeLiteral', literal: '3' }, { pattern: '3' }], ['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", "Input context and filtering, when content is '%s' and cursor at %i",
( (
@ -52,9 +66,16 @@ test.each([
identifier?: string identifier?: string
literal?: 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.code.value = code
input.selection.value = { start: cursorPos, end: cursorPos } input.selection.value = { start: cursorPos, end: cursorPos }
const context = input.context.value const context = input.context.value
@ -78,6 +99,7 @@ test.each([
} }
expect(filter.pattern).toStrictEqual(expFiltering.pattern) expect(filter.pattern).toStrictEqual(expFiltering.pattern)
expect(filter.qualifiedNamePattern).toStrictEqual(expFiltering.qualifiedNamePattern) expect(filter.qualifiedNamePattern).toStrictEqual(expFiltering.qualifiedNamePattern)
expect(filter.selfType).toStrictEqual(expFiltering.selfType)
}, },
) )
@ -195,7 +217,7 @@ test.each([
({ code, cursorPos, suggestion, expected, expectedCursorPos }) => { ({ code, cursorPos, suggestion, expected, expectedCursorPos }) => {
cursorPos = cursorPos ?? code.length cursorPos = cursorPos ?? code.length
expectedCursorPos = expectedCursorPos ?? expected.length expectedCursorPos = expectedCursorPos ?? expected.length
const input = new Input() const input = useComponentBrowserInput()
input.code.value = code input.code.value = code
input.selection.value = { start: cursorPos, end: cursorPos } input.selection.value = { start: cursorPos, end: cursorPos }
input.applySuggestion(suggestion) input.applySuggestion(suggestion)

View File

@ -13,10 +13,11 @@ import {
qnLastSegment, qnLastSegment,
qnParent, qnParent,
qnSplit, qnSplit,
tryIdentifier,
tryQualifiedName, tryQualifiedName,
type QualifiedName, type QualifiedName,
} from '@/util/qualifiedName' } from '@/util/qualifiedName'
import { computed, ref, type ComputedRef, type Ref } from 'vue' import { computed, ref, type ComputedRef } from 'vue'
/** Input's editing context. /** Input's editing context.
* *
@ -43,79 +44,70 @@ export type EditingContext =
| { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number } | { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number }
/** Component Browser Input Data */ /** Component Browser Input Data */
export class Input { export function useComponentBrowserInput() {
/** The current input's text (code). */ const code = ref('')
readonly code: Ref<string> const selection = ref({ start: 0, end: 0 })
/** 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>
constructor() { const context: ComputedRef<EditingContext> = computed(() => {
this.code = ref('') const input = code.value
this.selection = ref({ start: 0, end: 0 }) 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(() => { // Filter deduced from the access (`.` operator) chain written by user.
const input = this.code.value const accessChainFilter: ComputedRef<Filter> = computed(() => {
const cursorPosition = this.selection.value.start const ctx = context.value
if (cursorPosition === 0) return { type: 'insert', position: 0 } if (ctx.type === 'changeLiteral') return {}
const editedPart = cursorPosition - 1 if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {}
const inputAst = parseEnso(input) const opr = ctx.oprApp.lastOpr()
const editedAst = astContainingChar(editedPart, inputAst).values() const input = code.value
const leaf = editedAst.next() if (opr == null || !opr.ok || readTokenSpan(opr.value, input) !== '.') return {}
if (leaf.done) return { type: 'insert', position: cursorPosition } const selfType = pathAsSelfType(ctx.oprApp, input)
switch (leaf.value.type) { if (selfType != null) return { selfType }
case Ast.Tree.Type.Ident: const qn = pathAsQualifiedName(ctx.oprApp, input)
return { if (qn != null) return { qualifiedNamePattern: qn }
type: 'changeIdentifier', return {}
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),
}
}
})
const qualifiedNameFilter: ComputedRef<Filter> = computed(() => { const filter = computed(() => {
const code = this.code.value const input = code.value
const ctx = this.context.value const ctx = context.value
if (ctx.type === 'changeLiteral') return {} const filter = { ...accessChainFilter.value }
if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {} if (ctx.type === 'changeIdentifier') {
const opr = ctx.oprApp.lastOpr() const start =
if (opr == null || !opr.ok || readTokenSpan(opr.value, code) !== '.') return {} ctx.identifier.whitespaceStartInCodeParsed + ctx.identifier.whitespaceLengthInCodeParsed
const qn = Input.pathAsQualifiedName(ctx.oprApp, code) const end = selection.value.end
if (qn != null) return { qualifiedNamePattern: qn } filter.pattern = input.substring(start, end)
else return {} } else if (ctx.type === 'changeLiteral') {
}) filter.pattern = readAstSpan(ctx.literal, input)
}
return filter
})
this.filter = computed(() => { function readOprApp(
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(
leafParent: IteratorResult<Ast.Tree>, leafParent: IteratorResult<Ast.Tree>,
code: string,
editedAst?: Ast.Tree, editedAst?: Ast.Tree,
): { ): {
oprApp?: GeneralOprApp oprApp?: GeneralOprApp
@ -140,9 +132,18 @@ export class Input {
} }
} }
private static pathAsQualifiedName(accessOpr: GeneralOprApp, code: string): QualifiedName | null { function pathAsSelfType(accessOpr: GeneralOprApp, inputCode: string): QualifiedName | null {
const operandsAsIdents = Input.qnIdentifiers(accessOpr, code) if (accessOpr.lhs == null) return null
const segments = operandsAsIdents.map((ident) => readAstSpan(ident, code)) 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 rawQn = segments.join('.')
const qn = tryQualifiedName(rawQn) const qn = tryQualifiedName(rawQn)
return qn.ok ? qn.value : null return qn.ok ? qn.value : null
@ -155,18 +156,20 @@ export class Input {
* @param code The code from which `opr` was generated. * @param code The code from which `opr` was generated.
* @returns If all path segments are identifiers, return them * @returns If all path segments are identifiers, return them
*/ */
private static qnIdentifiers(opr: GeneralOprApp, code: string): Ast.Tree.Ident[] { function qnIdentifiers(opr: GeneralOprApp, inputCode: string): Ast.Tree.Ident[] {
const operandsAsIdents = Array.from(opr.operandsOfLeftAssocOprChain(code, '.'), (operand) => const operandsAsIdents = Array.from(
operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null, opr.operandsOfLeftAssocOprChain(inputCode, '.'),
(operand) =>
operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null,
).slice(0, -1) ).slice(0, -1)
if (operandsAsIdents.some((optIdent) => optIdent == null)) return [] if (operandsAsIdents.some((optIdent) => optIdent == null)) return []
else return operandsAsIdents as Ast.Tree.Ident[] else return operandsAsIdents as Ast.Tree.Ident[]
} }
/** Apply given suggested entry to the input. */ /** Apply given suggested entry to the input. */
applySuggestion(entry: SuggestionEntry) { function applySuggestion(entry: SuggestionEntry) {
const oldCode = this.code.value const oldCode = code.value
const changes = Array.from(this.inputChangesAfterApplying(entry)).reverse() const changes = Array.from(inputChangesAfterApplying(entry)).reverse()
const newCodeUpToLastChange = changes.reduce( const newCodeUpToLastChange = changes.reduce(
(builder, change) => { (builder, change) => {
const oldCodeFragment = oldCode.substring(builder.oldCodeIndex, change.range[0]) 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)) !isModule && (firstCharAfter == null || /^[a-zA-Z0-9_]$/.test(firstCharAfter))
const shouldMoveCursor = !isModule const shouldMoveCursor = !isModule
const newCursorPos = newCodeUpToLastChange.code.length + (shouldMoveCursor ? 1 : 0) const newCursorPos = newCodeUpToLastChange.code.length + (shouldMoveCursor ? 1 : 0)
this.code.value = code.value =
newCodeUpToLastChange.code + newCodeUpToLastChange.code +
(shouldInsertSpace ? ' ' : '') + (shouldInsertSpace ? ' ' : '') +
oldCode.substring(newCodeUpToLastChange.oldCodeIndex) 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. /** 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 * @returns The changes, starting from the rightmost. The `start` and `end` parameters refer
* to indices of "old" input content. * to indices of "old" input content.
*/ */
private *inputChangesAfterApplying( function* inputChangesAfterApplying(
entry: SuggestionEntry, entry: SuggestionEntry,
): Generator<{ range: [number, number]; str: string }> { ): Generator<{ range: [number, number]; str: string }> {
const ctx = this.context.value const ctx = context.value
const str = this.codeToBeInserted(entry) const str = codeToBeInserted(entry)
switch (ctx.type) { switch (ctx.type) {
case 'insert': { case 'insert': {
yield { range: [ctx.position, ctx.position], str } yield { range: [ctx.position, ctx.position], str }
@ -214,18 +217,18 @@ export class Input {
break break
} }
} }
yield* this.qnChangesAfterApplying(entry) yield* qnChangesAfterApplying(entry)
} }
private codeToBeInserted(entry: SuggestionEntry): string { function codeToBeInserted(entry: SuggestionEntry): string {
const ctx = this.context.value const ctx = context.value
const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null
const oprAppSpacing = const oprAppSpacing =
ctx.type === 'insert' && opr != null && opr.ok && opr.value.whitespaceLengthInCodeBuffer > 0 ctx.type === 'insert' && opr != null && opr.ok && opr.value.whitespaceLengthInCodeBuffer > 0
? ' '.repeat(opr.value.whitespaceLengthInCodeBuffer) ? ' '.repeat(opr.value.whitespaceLengthInCodeBuffer)
: '' : ''
const extendingAccessOprChain = 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. // Modules are special case, as we want to encourage user to continue writing path.
if (entry.kind === SuggestionKind.Module) { if (entry.kind === SuggestionKind.Module) {
if (extendingAccessOprChain) return `${oprAppSpacing}${entry.name}${oprAppSpacing}.` 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. /** All changes to the qualified name already written by the user.
* *
* See `inputChangesAfterApplying`. */ * See `inputChangesAfterApplying`. */
private *qnChangesAfterApplying( function* qnChangesAfterApplying(
entry: SuggestionEntry, entry: SuggestionEntry,
): Generator<{ range: [number, number]; str: string }> { ): Generator<{ range: [number, number]; str: string }> {
if (entry.selfType != null) return [] if (entry.selfType != null) return []
if (entry.kind === SuggestionKind.Local || entry.kind === SuggestionKind.Function) return [] if (entry.kind === SuggestionKind.Local || entry.kind === SuggestionKind.Function) return []
if (this.context.value.type === 'changeLiteral') return [] if (context.value.type === 'changeLiteral') return []
if (this.context.value.oprApp == null) return [] if (context.value.oprApp == null) return []
const writtenQn = Input.qnIdentifiers(this.context.value.oprApp, this.code.value).reverse() const writtenQn = qnIdentifiers(context.value.oprApp, code.value).reverse()
let containingQn = let containingQn =
entry.kind === SuggestionKind.Module entry.kind === SuggestionKind.Module
@ -265,4 +268,17 @@ export class Input {
containingQn = parent 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, qnLastSegment,
qnParent, qnParent,
qnSplit, qnSplit,
tryQualifiedName,
type Identifier, type Identifier,
type QualifiedName, type QualifiedName,
} from '@/util/qualifiedName' } from '@/util/qualifiedName'
import { unwrap } from '@/util/result'
import type { import type {
SuggestionEntryArgument, SuggestionEntryArgument,
SuggestionEntryScope, SuggestionEntryScope,
@ -77,7 +75,7 @@ export function entryQn(entry: SuggestionEntry): QualifiedName {
} else if (entry.memberOf) { } else if (entry.memberOf) {
return qnJoin(entry.memberOf, entry.name) return qnJoin(entry.memberOf, entry.name)
} else { } else {
return qnJoin(entry.definedIn, unwrap(tryQualifiedName(entry.name))) return qnJoin(entry.definedIn, entry.name)
} }
} }