diff --git a/lib/cql/index.html b/lib/cql/index.html index b0c7b0e..216b163 100644 --- a/lib/cql/index.html +++ b/lib/cql/index.html @@ -162,11 +162,19 @@ flex-direction: column; } - .CqlDebug__queryDiagramToken, - .CqlDebug__queryDiagramNode { + .CqlDebug__queryDiagramToken .CqlDebug__queryDiagramNode { margin-bottom: 6rem; } + .CqlDebug__queryDiagramToken .CqlDebug__queryIndex, + .CqlDebug__queryDiagramToken .CqlDebug__selection { + left: -50%; + } + + .CqlDebug__queryDiagramToken .CqlDebug__queryIndex { + position: relative; + } + .CqlDebug__queryDiagramNode > .CqlDebug__queryDiagramLabel { padding-top: 0rem; } diff --git a/lib/cql/src/cqlInput/editor/debug.ts b/lib/cql/src/cqlInput/editor/debug.ts index c66b60b..28d36ea 100644 --- a/lib/cql/src/cqlInput/editor/debug.ts +++ b/lib/cql/src/cqlInput/editor/debug.ts @@ -11,6 +11,7 @@ import { } from "../../lang/ast"; import { IS_READ_ONLY } from "./schema"; import { Selection } from "prosemirror-state"; +import { toProseMirrorTokens } from "./utils"; // Debugging and visualisation utilities. @@ -32,7 +33,11 @@ export const logNode = (doc: Node) => { }); }; -export const getDebugTokenHTML = (tokens: Token[], selection: Selection, mapping: Mapping) => { +export const getDebugTokenHTML = ( + tokens: Token[], + selection: Selection, + mapping: Mapping, +) => { let html = `
@@ -40,11 +45,14 @@ export const getDebugTokenHTML = (tokens: Token[], selection: Selection, mapping
Literal
`; + const invertedMapping = mapping.invert(); const mappedFrom = invertedMapping.map(selection.from); const mappedTo = invertedMapping.map(selection.to); - tokens.forEach((token, index) => { + const pmTokens = toProseMirrorTokens(tokens); + + pmTokens.forEach((token, index) => { html += `${Array(Math.max(1, token.lexeme.length)) .fill(undefined) .map((_, index) => { @@ -54,7 +62,7 @@ export const getDebugTokenHTML = (tokens: Token[], selection: Selection, mapping ); const literalChar = token.literal?.[index - literalOffset]; - const globalIndex = token.start + index + const globalIndex = token.from + index; return `
${globalIndex}
@@ -64,7 +72,9 @@ export const getDebugTokenHTML = (tokens: Token[], selection: Selection, mapping : "" } ${ - mappedTo === globalIndex ? `
$
` : "" + mappedTo === globalIndex + ? `
$
` + : "" } ${ lexemeChar !== undefined @@ -85,12 +95,18 @@ export const getDebugTokenHTML = (tokens: Token[], selection: Selection, mapping }) .join("")} ${ - tokens[index + 1]?.start > token.end + 1 && - tokens[index + 1]?.tokenType !== "EOF" && - token.tokenType !== "EOF" + pmTokens[index + 1]?.from > token.to && token.tokenType !== "EOF" ? `
${ - token.end + 1 - }
` + token.to + }
${ + mappedFrom === token.to + ? `
^
` + : "" + }${ + mappedTo === token.to + ? `
$
` + : "" + }
` : "" }`; }); @@ -173,10 +189,12 @@ export const getDebugMappingHTML = ( `
${pos}
- ${(queryPosMap[pos] ?? []).map( - ({ char }) => - `
${char}
`, - ).join(" ")} + ${(queryPosMap[pos] ?? []) + .map( + ({ char }) => + `
${char}
`, + ) + .join(" ")} ${ diff --git a/lib/cql/src/cqlInput/editor/plugins/cql.spec.ts b/lib/cql/src/cqlInput/editor/plugins/cql.spec.ts index ef85bf9..4162c96 100644 --- a/lib/cql/src/cqlInput/editor/plugins/cql.spec.ts +++ b/lib/cql/src/cqlInput/editor/plugins/cql.spec.ts @@ -538,7 +538,7 @@ describe("cql plugin", () => { const { editor, container, moveCaretToQueryPos } = createCqlEditor(queryStr); - await moveCaretToQueryPos(queryStr.length - 1); + await moveCaretToQueryPos(queryStr.length); await findByText(container, "1 day ago"); await editor.press("Enter"); @@ -549,7 +549,7 @@ describe("cql plugin", () => { const { editor, waitFor, container, moveCaretToQueryPos } = createCqlEditor(queryStr); - await moveCaretToQueryPos(queryStr.length - 1); + await moveCaretToQueryPos(queryStr.length); const popoverContainer = await findByTestId(container, typeaheadTestId); await findByText(popoverContainer, "1 day ago"); @@ -565,7 +565,7 @@ describe("cql plugin", () => { const { editor, waitFor, container, moveCaretToQueryPos } = createCqlEditor(queryStr); - await moveCaretToQueryPos(queryStr.length - 1); + await moveCaretToQueryPos(queryStr.length); const popoverContainer = await findByTestId(container, typeaheadTestId); await findByText(popoverContainer, "1 day ago"); diff --git a/lib/cql/src/cqlInput/editor/plugins/cql.ts b/lib/cql/src/cqlInput/editor/plugins/cql.ts index 0801259..0c8cddd 100644 --- a/lib/cql/src/cqlInput/editor/plugins/cql.ts +++ b/lib/cql/src/cqlInput/editor/plugins/cql.ts @@ -135,10 +135,10 @@ export const createCqlPlugin = ({ const queryBeforeParse = docToCqlStr(originalDoc); - const result = parser(queryBeforeParse); - const { tokens, queryAst, error, mapping } = mapResult(result); - - const newDoc = tokensToDoc(tokens); + const newDoc = tokensToDoc(mapResult(parser(queryBeforeParse)).tokens); + const queryAfterParse = docToCqlStr(newDoc); + const result = parser(queryAfterParse); + const { mapping, queryAst, tokens, error } = mapResult(result); const docSelection = new AllSelection(tr.doc); @@ -505,8 +505,15 @@ export const createCqlPlugin = ({ typeaheadPopover?.setIsPending(); } + if (view.state.selection.from !== view.state.selection.to) { + return; + } + try { - const suggestions = await typeahead.getSuggestions(queryAst); + const suggestions = await typeahead.getSuggestions( + queryAst, + mapping.invert().map(view.state.selection.from), + ); const mappedSuggestions = toMappedSuggestions(suggestions, mapping); if (view.hasFocus()) { typeaheadPopover?.updateSuggestions(mappedSuggestions); diff --git a/lib/cql/src/cqlInput/editor/utils.ts b/lib/cql/src/cqlInput/editor/utils.ts index 49c8d3c..72c3f6f 100644 --- a/lib/cql/src/cqlInput/editor/utils.ts +++ b/lib/cql/src/cqlInput/editor/utils.ts @@ -572,25 +572,29 @@ export type ProseMirrorToken = Omit & { * 0 1 2 3 */ export const toProseMirrorTokens = (tokens: Token[]): ProseMirrorToken[] => - tokens.map(({ start, end, ...token }) => ({ - ...token, - from: start, - to: end + 1, - })); + tokens.map(toProseMirrorToken); + +export const toProseMirrorToken = ({ start, end, ...token }: Token) => ({ + ...token, + from: start, + to: end + 1, +}); export const toMappedSuggestions = ( - typeaheadSuggestions: TypeaheadSuggestion[], + typeaheadSuggestion: TypeaheadSuggestion | undefined, mapping: Mapping, -) => - typeaheadSuggestions.map((suggestion) => { - const from = mapping.map(suggestion.from); - const to = mapping.map( - suggestion.to + 1, - suggestion.position === "chipKey" ? -1 : 0, - ); +) => { + if (!typeaheadSuggestion) { + return undefined; + } + const from = mapping.map(typeaheadSuggestion.from); + const to = mapping.map( + typeaheadSuggestion.to + 1, + typeaheadSuggestion.position === "chipKey" ? -1 : 0, + ); - return { ...suggestion, from, to } as MappedTypeaheadSuggestion; - }); + return { ...typeaheadSuggestion, from, to } as MappedTypeaheadSuggestion; +}; const toMappedError = (error: CqlError, mapping: Mapping) => ({ message: error.message, diff --git a/lib/cql/src/cqlInput/popover/TypeaheadPopover.ts b/lib/cql/src/cqlInput/popover/TypeaheadPopover.ts index 5e89c06..2ad694d 100644 --- a/lib/cql/src/cqlInput/popover/TypeaheadPopover.ts +++ b/lib/cql/src/cqlInput/popover/TypeaheadPopover.ts @@ -95,15 +95,16 @@ export class TypeaheadPopover extends Popover { }); } - public isRenderingNavigableMenu = () => this.isVisible && !!this.currentSuggestion?.suggestions.length; + public isRenderingNavigableMenu = () => + this.isVisible && !!this.currentSuggestion?.suggestions.length; public updateSuggestions = ( - typeaheadSuggestions: MappedTypeaheadSuggestion[], + typeaheadSuggestion?: MappedTypeaheadSuggestion, ) => { this.isPending = false; if ( this.view.isDestroyed || - !typeaheadSuggestions.length || + !typeaheadSuggestion || this.view.state.selection.from !== this.view.state.selection.to ) { this.currentSuggestion = undefined; @@ -112,19 +113,7 @@ export class TypeaheadPopover extends Popover { return; } - const { selection: currentSelection } = this.view.state; - const suggestionThatCoversSelection = typeaheadSuggestions.find( - ({ from, to }) => - currentSelection.from >= from && currentSelection.to <= to, - ); - - if (!suggestionThatCoversSelection) { - this.currentSuggestion = undefined; - this.hide(); - return; - } - - this.currentSuggestion = suggestionThatCoversSelection; + this.currentSuggestion = typeaheadSuggestion; this.show(); }; @@ -141,12 +130,20 @@ export class TypeaheadPopover extends Popover { } }; - protected show = () => { + protected show = async () => { if (!this.currentSuggestion) { return Promise.resolve(); } const { node } = this.view.domAtPos(this.currentSuggestion.from); - return super.show(node as HTMLElement); + + // The node given from `domAtPos` may be a text node + if (node && node instanceof HTMLElement) { + return super.show(node); + } + + console.warn( + `[cql]: Attempted to show popover, but domAtPos did not return an element for ${this.currentSuggestion.from}`, + ); }; public handleAction: ActionHandler = (action) => { diff --git a/lib/cql/src/lang/typeahead.spec.ts b/lib/cql/src/lang/typeahead.spec.ts index bb8892f..d822f79 100644 --- a/lib/cql/src/lang/typeahead.spec.ts +++ b/lib/cql/src/lang/typeahead.spec.ts @@ -17,6 +17,7 @@ describe("typeahead", () => { const getSuggestions = async ( query: string, + position: number, _typeahead: Typeahead = typeahead, ) => { const result = createParser()(query); @@ -25,269 +26,176 @@ describe("typeahead", () => { throw result.error; } - return await _typeahead.getSuggestions(result.queryAst); + return await _typeahead.getSuggestions(result.queryAst, position); }; it("should give all options for empty queryFields", async () => { - expect(await getSuggestions("")).toEqual([]); - - expect(await getSuggestions("+:")).toEqual([ - { - from: 1, - to: 1, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - new TextSuggestionOption( - "Sync", - "sync", - "Search synchronous list of tags", - ), - new TextSuggestionOption( - "Section", - "section", - "Search by content sections, e.g. section/news", - ), - new TextSuggestionOption( - "From date", - "from-date", - "The date to search from", - ), - new TextSuggestionOption("ID", "id", "The content ID"), - ], - type: "TEXT", - suffix: ":", - }, - ]); + expect(await getSuggestions("", 0)).toEqual(undefined); + + expect(await getSuggestions("+:", 1)).toEqual({ + from: 1, + to: 2, + position: "chipKey", + suggestions: [ + new TextSuggestionOption( + "Tag", + "tag", + "Search by content tags, e.g. sport/football", + ), + new TextSuggestionOption( + "Sync", + "sync", + "Search synchronous list of tags", + ), + new TextSuggestionOption( + "Section", + "section", + "Search by content sections, e.g. section/news", + ), + new TextSuggestionOption( + "From date", + "from-date", + "The date to search from", + ), + new TextSuggestionOption("ID", "id", "The content ID"), + ], + type: "TEXT", + suffix: ":", + }); }); it("should give typeahead suggestions for query meta keys", async () => { - const suggestions = await getSuggestions("+ta:"); - expect(suggestions).toEqual([ - { - from: 1, - to: 2, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - ], - type: "TEXT", - suffix: ":", - }, - ]); + const suggestions = await getSuggestions("+ta:", 1); + expect(suggestions).toEqual({ + from: 1, + to: 4, + position: "chipKey", + suggestions: [ + new TextSuggestionOption( + "Tag", + "tag", + "Search by content tags, e.g. sport/football", + ), + ], + type: "TEXT", + suffix: ":", + }); }); - it("should give typeahead suggestions for both query meta keys and values", async () => { - const suggestions = await getSuggestions("+tag:tags-are-magic"); - - expect(suggestions).toEqual([ - { - from: 1, - to: 3, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - ], - type: "TEXT", - suffix: ":", - }, - { - from: 5, - to: 18, - position: "chipValue", - suggestions: testTags, - type: "TEXT", - suffix: " ", - }, - ]); + it("should give typeahead suggestions for values", async () => { + const suggestions = await getSuggestions("+tag:tags-are-magic", 5); + + expect(suggestions).toEqual({ + from: 5, + to: 19, + position: "chipValue", + suggestions: testTags, + type: "TEXT", + suffix: " ", + }); }); it("should give typeahead suggestions in a case insensitive way for synchronous query meta keys across value and label", async () => { - const expectedSuggestions: TypeaheadSuggestion[] = [ - { - from: 1, - position: "chipKey", - suffix: ":", - suggestions: [ - new TextSuggestionOption( - "Sync", - "sync", - "Search synchronous list of tags", - ), - ], - to: 4, - type: "TEXT", - }, - { - from: 6, - to: 8, - position: "chipValue", - suggestions: [ - new TextSuggestionOption( - "abc DEF", - "GHI jkl", - "A tag with a mix of upper and lowercase strings", - ), - ], - type: "TEXT", - suffix: " ", - }, - ]; - - const suggestionsUppercaseLabelQuery = await getSuggestions("+sync:ABC"); + const expectedValue: TypeaheadSuggestion = { + from: 6, + to: 9, + position: "chipValue", + suggestions: [ + new TextSuggestionOption( + "abc DEF", + "GHI jkl", + "A tag with a mix of upper and lowercase strings", + ), + ], + type: "TEXT", + suffix: " ", + }; + const suggestionsUppercaseLabelQuery = await getSuggestions("+sync:ABC", 6); - expect(suggestionsUppercaseLabelQuery).toEqual(expectedSuggestions); + expect(suggestionsUppercaseLabelQuery).toEqual(expectedValue); - const suggestionsLowercaseLabelQuery = await getSuggestions("+sync:def"); + const suggestionsLowercaseLabelQuery = await getSuggestions("+sync:def", 6); - expect(suggestionsLowercaseLabelQuery).toEqual(expectedSuggestions); + expect(suggestionsLowercaseLabelQuery).toEqual(expectedValue); - const suggestionsUppercaseValueQuery = await getSuggestions("+sync:JKL"); + const suggestionsUppercaseValueQuery = await getSuggestions("+sync:JKL", 6); - expect(suggestionsUppercaseValueQuery).toEqual(expectedSuggestions); + expect(suggestionsUppercaseValueQuery).toEqual(expectedValue); - const suggestionsLowercaseValueQuery = await getSuggestions("+sync:ghi"); + const suggestionsLowercaseValueQuery = await getSuggestions("+sync:ghi", 6); - expect(suggestionsLowercaseValueQuery).toEqual(expectedSuggestions); + expect(suggestionsLowercaseValueQuery).toEqual(expectedValue); - const suggestionsForNonMatchingQuery = await getSuggestions("+sync:mno"); + const suggestionsForNonMatchingQuery = await getSuggestions("+sync:mno", 6); expect(suggestionsLowercaseValueQuery).not.toEqual( suggestionsForNonMatchingQuery, ); }); it("should give value suggestions for an empty string", async () => { - const suggestions = await getSuggestions("+tag:"); - expect(suggestions).toEqual([ - { - from: 1, - to: 3, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - ], - type: "TEXT", - suffix: ":", - }, - { - from: 5, - to: 5, - position: "chipValue", - suggestions: testTags, - type: "TEXT", - suffix: " ", - }, - ]); + const suggestions = await getSuggestions("+tag:", 5); + expect(suggestions).toEqual({ + from: 5, + to: 6, + position: "chipValue", + suggestions: testTags, + type: "TEXT", + suffix: " ", + }); }); it("should give a suggestion of type DATE given e.g. 'from-date'", async () => { - const suggestions = await getSuggestions("+from-date:"); - - expect(suggestions).toEqual([ - { - from: 1, - to: 9, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "From date", - "from-date", - "The date to search from", - ), - ], - type: "TEXT", - suffix: ":", - }, - { - from: 11, - to: 11, - position: "chipValue", - suggestions: [ - new DateSuggestionOption("1 day ago", "-1d"), - new DateSuggestionOption("7 days ago", "-7d"), - new DateSuggestionOption("14 days ago", "-14d"), - new DateSuggestionOption("30 days ago", "-30d"), - new DateSuggestionOption("1 year ago", "-1y"), - ], - type: "DATE", - suffix: " ", - }, - ]); + const suggestions = await getSuggestions("+from-date:", 11); + + expect(suggestions).toEqual({ + from: 11, + to: 12, + position: "chipValue", + suggestions: [ + new DateSuggestionOption("1 day ago", "-1d"), + new DateSuggestionOption("7 days ago", "-7d"), + new DateSuggestionOption("14 days ago", "-14d"), + new DateSuggestionOption("30 days ago", "-30d"), + new DateSuggestionOption("1 year ago", "-1y"), + ], + type: "DATE", + suffix: " ", + }); }); - it("should give suggestions for multiple tags", async () => { - const suggestions = await getSuggestions("+tag:a +:"); - - expect(suggestions).toEqual([ - { - from: 1, - to: 3, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - ], - type: "TEXT", - suffix: ":", - }, - { - from: 5, - to: 5, - position: "chipValue", - suggestions: testTags, - type: "TEXT", - suffix: " ", - }, - { - from: 8, - to: 8, - position: "chipKey", - suggestions: [ - new TextSuggestionOption( - "Tag", - "tag", - "Search by content tags, e.g. sport/football", - ), - new TextSuggestionOption( - "Sync", - "sync", - "Search synchronous list of tags", - ), - new TextSuggestionOption( - "Section", - "section", - "Search by content sections, e.g. section/news", - ), - new TextSuggestionOption( - "From date", - "from-date", - "The date to search from", - ), - new TextSuggestionOption("ID", "id", "The content ID"), - ], - type: "TEXT", - suffix: ":", - }, - ]); + it("should give suggestions for the correct tag where there is more than one", async () => { + const suggestions = await getSuggestions("+tag:a +:", 8); + + expect(suggestions).toEqual({ + from: 8, + to: 9, + position: "chipKey", + suggestions: [ + new TextSuggestionOption( + "Tag", + "tag", + "Search by content tags, e.g. sport/football", + ), + new TextSuggestionOption( + "Sync", + "sync", + "Search synchronous list of tags", + ), + new TextSuggestionOption( + "Section", + "section", + "Search by content sections, e.g. section/news", + ), + new TextSuggestionOption( + "From date", + "from-date", + "The date to search from", + ), + new TextSuggestionOption("ID", "id", "The content ID"), + ], + type: "TEXT", + suffix: ":", + }); }); it("should suggest keys that start with the search string first", async () => { @@ -296,8 +204,8 @@ describe("typeahead", () => { ); expect( - (await getSuggestions("+g:", typeahead)).flatMap((a) => - a.suggestions.map((s) => s.value), + (await getSuggestions("+g:", 1, typeahead))?.suggestions.map( + (s) => s.value, ), ).toEqual(["gnat", "tag", "stage"]); }); diff --git a/lib/cql/src/lang/typeahead.ts b/lib/cql/src/lang/typeahead.ts index 071f92f..99d4270 100644 --- a/lib/cql/src/lang/typeahead.ts +++ b/lib/cql/src/lang/typeahead.ts @@ -1,12 +1,12 @@ -import { CqlQuery, CqlField } from "./ast"; -import { Token } from "./token"; +import { ProseMirrorToken } from "../cqlInput/editor/utils"; +import { CqlQuery } from "./ast"; import { DateSuggestionOption, TextSuggestionOption, TypeaheadSuggestion, SuggestionType, } from "./types"; -import { getCqlFieldsFromCqlBinary } from "./utils"; +import { getAstNodeAtPos } from "./utils"; type TypeaheadResolver = | ((str: string, signal?: AbortSignal) => Promise) @@ -81,16 +81,17 @@ export class Typeahead { ); } - public getSuggestions( + public async getSuggestions( program: CqlQuery, + position: number, signal?: AbortSignal, - ): Promise { + ): Promise { return new Promise((resolve, reject) => { // Abort existing fetch, if it exists this.abortController?.abort(); if (!program.content) { - return resolve([]); + return resolve(undefined); } const abortController = new AbortController(); @@ -99,41 +100,45 @@ export class Typeahead { reject(new DOMException("Aborted", "AbortError")); }); - const eventuallySuggestions = getCqlFieldsFromCqlBinary( - program.content, - ).flatMap((queryField) => this.suggestCqlField(queryField, signal)); + const maybeSuggestionAtPos = getAstNodeAtPos(program.content, position); - return Promise.all(eventuallySuggestions) - .then((suggestions) => resolve(suggestions.flat())) - .catch(reject); + if (!maybeSuggestionAtPos) { + return resolve(undefined); + } + + const { key, value } = maybeSuggestionAtPos; + + resolve(this.suggestCqlField(key, value, signal)); }); } - private getSuggestionsForKeyToken(keyToken: Token): TypeaheadSuggestion[] { + private getSuggestionsForChipKey( + keyToken: ProseMirrorToken, + ): TypeaheadSuggestion | undefined { const suggestions = this.suggestFieldKey(keyToken.literal ?? ""); if (!suggestions) { - return []; + return undefined; } - return [ - { - from: keyToken.start, - to: Math.max(keyToken.start, keyToken.end - 1), // Do not include ':' - position: "chipKey", - suggestions, - type: "TEXT", - suffix: ":", - }, - ]; + return { + from: keyToken.from, + to: Math.max(keyToken.to, keyToken.to - 1), // Do not include ':' + position: "chipKey", + suggestions, + type: "TEXT", + suffix: ":", + }; } private async suggestCqlField( - q: CqlField, + key: ProseMirrorToken, + value?: ProseMirrorToken, signal?: AbortSignal, - ): Promise { - const { key, value } = q; - const keySuggestions = this.getSuggestionsForKeyToken(key); + ): Promise { + if (!value) { + return this.getSuggestionsForChipKey(key); + } const maybeValueSuggestions = this.suggestFieldValue( key.literal ?? "", @@ -142,20 +147,19 @@ export class Typeahead { ); if (!maybeValueSuggestions) { - return Promise.resolve(keySuggestions); + return; } - return maybeValueSuggestions.suggestions.then((suggestions) => [ - ...keySuggestions, - { - from: value ? value.start : key.end + 1, - to: value ? value.end : key.end + 1, - position: "chipValue", - suggestions, - type: maybeValueSuggestions.type, - suffix: " ", - } as TypeaheadSuggestion, - ]); + const suggestions = await maybeValueSuggestions.suggestions; + + return { + from: value ? value.from : key.from, + to: value ? value.to : key.to, + position: "chipValue", + suggestions, + type: maybeValueSuggestions.type, + suffix: " ", + } as TypeaheadSuggestion; } private suggestFieldKey(str: string): TextSuggestionOption[] | undefined { diff --git a/lib/cql/src/lang/utils.ts b/lib/cql/src/lang/utils.ts index 7fddf4b..798b48e 100644 --- a/lib/cql/src/lang/utils.ts +++ b/lib/cql/src/lang/utils.ts @@ -1,4 +1,10 @@ +import { + ProseMirrorToken, + toProseMirrorToken, + toProseMirrorTokens, +} from "../cqlInput/editor/utils"; import { CqlBinary, CqlExpr, CqlField } from "./ast"; +import { Token, TokenType } from "./token"; const whitespaceR = /\s/; export const hasWhitespace = (str: string) => !!str.match(whitespaceR); @@ -56,6 +62,89 @@ export function* getPermutations( return permutation.slice(); } +type SuggestionPos = + | undefined + | { key: ProseMirrorToken; value: undefined } + | { key: ProseMirrorToken; value: ProseMirrorToken }; + +export const getAstNodeAtPos = ( + queryBinary: CqlBinary, + position: number, +): SuggestionPos => { + return ( + getAstNodeAtPosExpr(queryBinary.left, position) ?? + (queryBinary.right + ? getAstNodeAtPos(queryBinary.right.binary, position) + : undefined) + ); +}; + +export const getAstNodeAtPosExpr = ( + expr: CqlExpr, + position: number, +): SuggestionPos => { + switch (expr.content.type) { + case "CqlStr": { + const key = isWithinRange(expr.content.token, position); + + return key + ? { + key, + value: undefined, + } + : undefined; + } + case "CqlBinary": + return getAstNodeAtPos(expr.content, position); + case "CqlGroup": + return getAstNodeAtPos(expr.content.content, position); + case "CqlField": { + const key = isWithinRange(expr.content.key, position); + const value = isWithinRange(expr.content.value, position); + + if (!key && !value) { + return undefined; + } + + if (key && !value) { + return position === key.to + ? { + key, + // Add an empty value here to signal that we are in value position + value: toProseMirrorToken( + new Token( + TokenType.CHIP_VALUE, + "", + undefined, + position, + position, + ), + ), + } + : { key, value: undefined }; + } + + return { + key: toProseMirrorToken(expr.content.key), + value, + }; + } + } +}; + +const isWithinRange = ( + token: Token | undefined, + position: number, +): ProseMirrorToken | undefined => { + if (!token) { + return undefined; + } + + const [pmToken] = toProseMirrorTokens([token]); + return position >= pmToken.from && position <= pmToken.to + ? pmToken + : undefined; +}; export const getCqlFieldsFromCqlBinary = (queryBinary: CqlBinary): CqlField[] => getCqlFieldsFromQueryExpr(queryBinary.left).concat( queryBinary.right diff --git a/lib/cql/src/page.ts b/lib/cql/src/page.ts index 23f7f9f..668b7d4 100644 --- a/lib/cql/src/page.ts +++ b/lib/cql/src/page.ts @@ -63,7 +63,7 @@ const handleDebugChangeEvent = (e: CustomEvent) => { debugMappingContainer.innerHTML = `

Original query:

${getOriginalQueryHTML(queryStr)} -

Tokenises to:

+

Tokenises to (ProseMirror positions):

${getDebugTokenHTML(tokens, selection, mapping)} ${ queryAst