diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b6609d9..895f651 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "10.1.2" + ".": "10.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 307da12..f1291c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [10.2.0](https://github.com/javalent/dice-roller/compare/10.1.2...10.2.0) (2023-10-27) + + +### Features + +* Enables changing the font of rendered dice ([c16d3c0](https://github.com/javalent/dice-roller/commit/c16d3c07fd71675fd56facaf94045dca51eeecb1)) + + +### Bug Fixes + +* **dicetray:** error handling for roll button ([#264](https://github.com/javalent/dice-roller/issues/264)) ([0f2c599](https://github.com/javalent/dice-roller/commit/0f2c599c242b099d7bd3811314cc95486fffca7c)) +* Fixes average calculation ([bf87aa8](https://github.com/javalent/dice-roller/commit/bf87aa80565b4e45cbb4c88cc9b89a3c49e82be5)) +* **lexer:** handle unary minus operator ([#262](https://github.com/javalent/dice-roller/issues/262)) ([139cbfa](https://github.com/javalent/dice-roller/commit/139cbfa6c7927686a3d2bedda6732c3ce3df7fef)) + ## [10.1.2](https://github.com/javalent/dice-roller/compare/10.1.1...10.1.2) (2023-10-20) diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 4db55ca..f73d148 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -25,6 +25,7 @@ esbuild bundle: true, external: [ "obsidian", + "get-fonts", "electron", "codemirror", "@codemirror/autocomplete", diff --git a/manifest.json b/manifest.json index 351fce0..95ab577 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-dice-roller", "name": "Dice Roller", - "version": "10.1.2", + "version": "10.2.0", "minAppVersion": "0.12.15", "description": "Inline dice rolling for Obsidian.md", "author": "Jeremy Valentine", diff --git a/package-lock.json b/package-lock.json index 3702c27..8d57ff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-dice-roller", - "version": "10.1.2", + "version": "10.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "obsidian-dice-roller", - "version": "10.1.2", + "version": "10.2.0", "license": "MIT", "dependencies": { "obsidian-dice-roller": "^8.13.12" @@ -17,6 +17,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@popperjs/core": "^2.11.8", "@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-typescript": "^6.0.0", @@ -673,6 +674,16 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-15.1.0.tgz", @@ -4105,9 +4116,9 @@ } }, "node_modules/postcss": { - "version": "8.4.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.26.tgz", - "integrity": "sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -6300,6 +6311,12 @@ "fastq": "^1.6.0" } }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true + }, "@rollup/plugin-commonjs": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-15.1.0.tgz", @@ -8947,9 +8964,9 @@ } }, "postcss": { - "version": "8.4.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.26.tgz", - "integrity": "sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "requires": { "nanoid": "^3.3.6", diff --git a/package.json b/package.json index b0b87b6..6d75054 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-dice-roller", - "version": "10.1.2", + "version": "10.2.0", "description": "Inline dice rolling for Obsidian.md", "main": "", "types": "./@types/index.d.ts", @@ -27,6 +27,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", + "@popperjs/core": "^2.11.8", "@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-typescript": "^6.0.0", diff --git a/src/main.ts b/src/main.ts index dab9afa..9242b27 100644 --- a/src/main.ts +++ b/src/main.ts @@ -163,6 +163,7 @@ interface DiceRollerSettings { scaler: number; diceColor: string; textColor: string; + textFont: string; showLeafOnStartup: boolean; customFormulas: string[]; @@ -201,6 +202,7 @@ export const DEFAULT_SETTINGS: DiceRollerSettings = { scaler: 1, diceColor: "#202020", textColor: "#ffffff", + textFont: "Arial", showLeafOnStartup: true, showDice: true, displayAsEmbed: true, diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts index 33faaf9..c414e17 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -4,6 +4,7 @@ import * as moo from "moo"; import DiceRollerPlugin from "src/main"; import { Conditional } from "src/types"; import { Parser } from "./parser"; +import copy from "fast-copy"; export const TAG_REGEX = /(?:\d+[Dd])?#(?:[\p{Letter}\p{Emoji_Presentation}\w/-]+)(?:\|(?:[+-]))?(?:\|(?:[^+-]+))?/u; @@ -133,7 +134,7 @@ export default class Lexer { "^": exponent }); } - parse(input: string) { + parse(input: string): LexicalToken[] { const tokens = Array.from(this.lexer.reset(input)); this.lexer.reset(); return this.parser.parse(this.transform(tokens)); @@ -142,18 +143,37 @@ export default class Lexer { tokens = tokens.filter((token) => { return token.type != "WS"; }); - let clone: LexicalToken[] = []; - /** If the first token is a negative sign and the second is a dice roller, just make the dice roller negative. */ - if (tokens.length >= 2) { - if ( - (tokens[0].type === "-" || - (tokens[0].type === "math" && tokens[0].value === "-")) && - tokens[1].type === "dice" - ) { - tokens[1].value = `-${tokens[1].value}`; - tokens.shift(); + + let isPlus = (t: moo.Token) => + t.type === "+" || (t.type === "math" && t.value === "+"); + let isMinus = (t: moo.Token) => + t.type === "-" || (t.type === "math" && t.value === "-"); + let isPlusOrMinus = (t: moo.Token) => isPlus(t) || isMinus(t); + let peek = (arr: moo.Token[]) => arr[arr.length - 1]; + let replaceTop = (arr: moo.Token[], newTop: moo.Token) => + arr.splice(arr.length - 1, 1, newTop); + + tokens = tokens.reduce((acc, e) => { + if (acc.length == 0) { + acc.push(e); + } else { + let top = peek(acc); + + if (isPlusOrMinus(top) && isPlusOrMinus(e)) { + if (isMinus(top) != isMinus(e)) { + // one minus => minus + if (!isMinus(top)) replaceTop(acc, e); + } else if (isMinus(top)) { + top.type = top.type === "math" ? top.type : "+"; + top.value = "+"; + } + } else { + acc.push(e); + } } - } + return acc; + }, [] as moo.Token[]); + let clone: LexicalToken[] = []; for (const token of tokens) { if (token.type == "condition" && clone.length > 0) { const previous = clone[clone.length - 1]; diff --git a/src/renderer/geometries.ts b/src/renderer/geometries.ts index d30d82e..a43ff7a 100644 --- a/src/renderer/geometries.ts +++ b/src/renderer/geometries.ts @@ -22,10 +22,18 @@ const MATERIAL_OPTIONS = { shininess: 60, flatShading: true }; -const DEFAULT_DICE_OPTIONS = { +const DEFAULT_DICE_OPTIONS: DiceOptions = { diceColor: "#202020", - textColor: "#ffffff" + textColor: "#ffffff", + textFont: "Arial", }; + +interface DiceOptions { + diceColor: string; + textColor: string; + textFont: string; +} + export default abstract class DiceGeometry { body: Body; chamferGeometry: { vectors: Vector3[]; faces: any[][] }; @@ -76,7 +84,7 @@ export default abstract class DiceGeometry { constructor( public w: number, public h: number, - public options = { + public options: Partial = { diceColor: "#202020", textColor: "#aaaaaa" }, @@ -86,6 +94,8 @@ export default abstract class DiceGeometry { ...DEFAULT_DICE_OPTIONS, ...options }; + + this.fontFace = this.options.textFont; } setColor({ diceColor, @@ -454,7 +464,7 @@ class D20DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#171120", textColor: "#FF0000" }, + options: Partial = { diceColor: "#171120", textColor: "#FF0000" }, scaler: number ) { super(w, h, options, scaler); @@ -504,7 +514,7 @@ class D12DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#7339BE", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#7339BE", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -571,7 +581,7 @@ class D10DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#c74749", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#c74749", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -622,7 +632,7 @@ class D100DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#7a2c2d", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#7a2c2d", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -668,7 +678,7 @@ class D8DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#5eb0c5", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#5eb0c5", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -705,7 +715,7 @@ class D6DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#d68316", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#d68316", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -772,7 +782,7 @@ class D4DiceGeometry extends DiceGeometry { constructor( w: number, h: number, - options = { diceColor: "#93b139", textColor: "#FFFFFF" }, + options: Partial = { diceColor: "#93b139", textColor: "#FFFFFF" }, scaler: number ) { super(w, h, options, scaler); @@ -869,7 +879,7 @@ abstract class GenesysD12DiceGeometry extends GenesysDice { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -921,7 +931,7 @@ export class GenesysProficiencyDiceGeometry extends GenesysD12DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -949,7 +959,7 @@ export class GenesysChallengeDiceGeometry extends GenesysD12DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -991,7 +1001,7 @@ export class GenesysAbilityDiceGeometry extends GenesysD8DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -1003,7 +1013,7 @@ export class GenesysDifficultyDiceGeometry extends GenesysD8DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -1045,7 +1055,7 @@ export class GenesysBoostDiceGeometry extends GenesysD6DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); @@ -1057,7 +1067,7 @@ export class GenesysSetbackDiceGeometry extends GenesysD6DiceGeometry { constructor( w: number, h: number, - options = DEFAULT_DICE_OPTIONS, + options: Partial = DEFAULT_DICE_OPTIONS, scaler: number ) { super(w, h, options, scaler); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 5ceeb26..995cb08 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -61,6 +61,7 @@ export type RendererData = { colorfulDice: boolean; scaler: number; renderTime: number; + textFont: string; }; export default class DiceRenderer extends Component { @@ -110,7 +111,8 @@ export default class DiceRenderer extends Component { this.data = data; this.factory.width = this.WIDTH; this.factory.height = this.HEIGHT; - this.factory.updateDice(); + + this.factory.updateDice(this.data); } constructor(public data: RendererData) { super(); @@ -147,7 +149,8 @@ export default class DiceRenderer extends Component { diceColor: this.data.diceColor, textColor: this.data.textColor, colorfulDice: this.data.colorfulDice, - scaler: this.data.scaler + scaler: this.data.scaler, + textFont: this.data.textFont }); onload() { @@ -276,7 +279,7 @@ export default class DiceRenderer extends Component { this.factory.width = this.display.currentWidth; this.factory.height = this.display.currentHeight; - this.factory.updateDice(); + this.factory.updateDice(this.data); this.cameraHeight.medium = this.cameraHeight.max / 1.5; this.cameraHeight.far = this.cameraHeight.max; @@ -598,11 +601,13 @@ class LocalWorld { } } +type FactoryData = Omit; class DiceFactory extends Component { dice: Record = {}; get colors() { const diceColor = this.options.diceColor; const textColor = this.options.textColor; + const textFont = this.options.textFont; // If we want colorful dice then just use the default colors in the geometry if (this.options.colorfulDice) { @@ -611,23 +616,19 @@ class DiceFactory extends Component { return { diceColor, - textColor + textFont }; } constructor( public width: number, public height: number, - public options: { - diceColor: string; - textColor: string; - scaler: number; - colorfulDice: boolean; - } + public options: FactoryData ) { super(); this.buildDice(); } - updateDice = debounce(() => { + updateDice = debounce((options: FactoryData) => { + this.options = { ...options }; this.dispose(); this.buildDice(); }, 200); diff --git a/src/settings/settings.ts b/src/settings/settings.ts index dd4d352..297b90c 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -4,6 +4,7 @@ import { DropdownComponent, ExtraButtonComponent, Notice, + Platform, PluginSettingTab, setIcon, Setting, @@ -15,7 +16,22 @@ import type DiceRoller from "../main"; import { DEFAULT_SETTINGS } from "../main"; import { DiceIcon, IconManager, IconShapes } from "src/view/view.icons"; import { generateSlug } from "random-word-slugs"; +import { FontSuggestionModal } from "src/suggester/fonts"; +declare var require: (id: "get-fonts") => { getFonts: () => Promise }; + +declare global { + interface Window { + Capacitor?: { + isPluginAvailable(plugin: string): boolean; + Plugins: { + App: { + getFonts: () => Promise; + }; + }; + }; + } +} export default class SettingTab extends PluginSettingTab { iconsEl: HTMLDivElement; contentEl: HTMLDivElement; @@ -23,6 +39,31 @@ export default class SettingTab extends PluginSettingTab { super(app, plugin); this.plugin = plugin; } + async getFonts() { + let fonts: string[] = []; + try { + if ( + Platform.isMobile && + window?.Capacitor?.isPluginAvailable("App") + ) { + fonts = await window?.Capacitor?.Plugins["App"] + ?.getFonts() + ?.catch((e) => []); + } else { + fonts = await require("get-fonts") + .getFonts() + .catch((e) => []); + } + } catch (e) {} + + let fontSet: Set = new Set(); + + for (const font of fonts) { + fontSet.add(font); + } + + return [...fontSet].sort(); + } async display(): Promise { let { containerEl } = this; @@ -596,7 +637,29 @@ export default class SettingTab extends PluginSettingTab { this.plugin.saveSettings(); }); }); - + new Setting(containerEl) + .setName("Font for dice") + .setDesc("Select the font to use for the dice") + .addText(async (t) => { + const set = async () => { + this.plugin.data.textFont = t.getValue(); + await this.plugin.saveSettings(); + this.plugin.renderer.setData(this.plugin.getRendererData()); + }; + const folderModal = new FontSuggestionModal( + this.app, + t, + await this.getFonts() + ); + folderModal.onClose = () => { + t.setValue(folderModal.item); + set(); + }; + t.setValue(this.plugin.data.textFont); + t.inputEl.onblur = async () => { + set(); + }; + }); const diceColor = new Setting(containerEl) .setName("Dice Base Color") .setDesc("Rendered dice will be this color."); diff --git a/src/suggester/fonts.ts b/src/suggester/fonts.ts new file mode 100644 index 0000000..eda023d --- /dev/null +++ b/src/suggester/fonts.ts @@ -0,0 +1,63 @@ +import { + TFolder, + TextComponent, + type CachedMetadata, + App, + type FuzzyMatch +} from "obsidian"; +import { SuggestionModal } from "./suggester"; + +export class FontSuggestionModal extends SuggestionModal { + text: TextComponent; + cache: CachedMetadata; + constructor(app: App, input: TextComponent, items: string[]) { + super(app, input.inputEl, items); + this.text = input; + } + getItemText(item: string) { + return item; + } + onChooseItem(item: string) { + this.text.setValue(item); + this.item = item; + } + selectSuggestion({ item }: FuzzyMatch) { + let link = item; + this.text.setValue(link); + this.onClose(); + + this.close(); + } + renderSuggestion(result: FuzzyMatch, el: HTMLElement) { + let { item, match: matches } = result || {}; + let content = el.createDiv({ + cls: "suggestion-content", + attr: { + style: `font-family: "${item}"` + } + }); + if (!item) { + content.setText(this.emptyStateText); + content.parentElement?.addClass("is-selected"); + return; + } + + let pathLength = item.length - item.length; + const matchElements = matches.matches.map((m) => { + return createSpan("suggestion-highlight"); + }); + for (let i = pathLength; i < item.length; i++) { + let match = matches.matches.find((m) => m[0] === i); + if (match) { + let element = matchElements[matches.matches.indexOf(match)]; + content.appendChild(element); + element.appendText(item.substring(match[0], match[1])); + + i += match[1] - match[0] - 1; + continue; + } + + content.appendText(item[i]); + } + } +} diff --git a/src/suggester/path.ts b/src/suggester/path.ts new file mode 100644 index 0000000..88851d3 --- /dev/null +++ b/src/suggester/path.ts @@ -0,0 +1,189 @@ +import { SuggestionModal } from "./suggester"; +import { + type FuzzyMatch, + TFile, + type BlockCache, + type HeadingCache, + type CachedMetadata, + TextComponent, + App, +} from "obsidian"; + +export default class PathSuggestionModal extends SuggestionModal< + TFile | BlockCache | HeadingCache +> { + file: TFile | null; + files: TFile[]; + text: TextComponent; + cache: CachedMetadata | null; + constructor(app: App, input: TextComponent, items: TFile[]) { + super(app, input.inputEl, items); + this.files = [...items]; + this.text = input; + + this.createPrompts(); + + this.inputEl.addEventListener("input", this.getFile.bind(this)); + } + createPrompts() { + this.createPrompt([ + createSpan({ + cls: "prompt-instruction-command", + text: "Type #", + }), + createSpan({ text: "to link heading" }), + ]); + this.createPrompt([ + createSpan({ + cls: "prompt-instruction-command", + text: "Type ^", + }), + createSpan({ text: "to link blocks" }), + ]); + this.createPrompt([ + createSpan({ + cls: "prompt-instruction-command", + text: "Note: ", + }), + createSpan({ + text: "Blocks must have been created already", + }), + ]); + } + getFile() { + const v = this.inputEl.value, + file = this.app.metadataCache.getFirstLinkpathDest( + v.split(/[\^#]/).shift() || "", + "" + ); + if (file == this.file) return; + this.file = file; + if (this.file) + this.cache = this.app.metadataCache.getFileCache(this.file); + this.onInputChanged(); + } + getItemText(item: TFile | HeadingCache | BlockCache) { + if (item instanceof TFile) return item.path; + if (Object.prototype.hasOwnProperty.call(item, "heading")) { + return (item).heading; + } + if (Object.prototype.hasOwnProperty.call(item, "id")) { + return (item).id; + } + return ""; + } + onChooseItem(item: TFile | HeadingCache | BlockCache) { + if (item instanceof TFile) { + this.text.setValue(item.basename); + this.file = item; + this.cache = this.app.metadataCache.getFileCache(this.file); + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + this.text.setValue( + this.file?.basename + "#" + (item).heading + ); + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + this.text.setValue( + this.file?.basename + "^" + (item).id + ); + } + } + link: string; + selectSuggestion({ item }: FuzzyMatch) { + let link: string = ""; + if (item instanceof TFile) { + this.file = item; + link = item.basename; + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + link = this.file?.basename + "#" + (item).heading; + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + link = this.file?.basename + "^" + (item).id; + } + const path = this.file?.path.split("/").slice(0, -1) ?? []; + if (path.length) { + this.link = path.join("/") + "/" + link; + } else { + this.link = link; + } + this.text.setValue(link); + + this.close(); + this.onClose(); + } + renderSuggestion( + result: FuzzyMatch, + el: HTMLElement + ) { + let { item, match: matches } = result || {}; + let content = el.createDiv({ + cls: "suggestion-content", + }); + if (!item) { + content.setText(this.emptyStateText); + content.parentElement?.addClass("is-selected"); + return; + } + + if (item instanceof TFile) { + let pathLength = item.path.length - item.name.length; + const matchElements = matches.matches.map((m) => { + return createSpan("suggestion-highlight"); + }); + for ( + let i = pathLength; + i < item.path.length - item.extension.length - 1; + i++ + ) { + let match = matches.matches.find((m) => m[0] === i); + if (match) { + let element = matchElements[matches.matches.indexOf(match)]; + content.appendChild(element); + element.appendText(item.path.substring(match[0], match[1])); + + i += match[1] - match[0] - 1; + continue; + } + + content.appendText(item.path[i]); + } + el.createDiv({ + cls: "suggestion-note", + text: item.path, + }); + } else if (Object.prototype.hasOwnProperty.call(item, "heading")) { + content.setText((item).heading); + content.prepend( + createSpan({ + cls: "suggestion-flair", + text: `H${(item).level}`, + }) + ); + } else if (Object.prototype.hasOwnProperty.call(item, "id")) { + content.setText((item).id); + } + } + get headings() { + if (!this.file) return []; + if (!this.cache) { + this.cache = this.app.metadataCache.getFileCache(this.file); + } + return this.cache?.headings ?? []; + } + get blocks() { + if (!this.file) return []; + if (!this.cache) { + this.cache = this.app.metadataCache.getFileCache(this.file); + } + return Object.values(this.cache?.blocks ?? {}) ?? []; + } + getItems() { + const v = this.inputEl.value; + if (/#/.test(v)) { + this.modifyInput = (i) => i.split(/#/).pop(); + return this.headings; + } else if (/\^/.test(v)) { + this.modifyInput = (i) => i.split(/\^/).pop(); + return this.blocks; + } + return this.files; + } +} diff --git a/src/suggester/suggester.ts b/src/suggester/suggester.ts new file mode 100644 index 0000000..de17ab8 --- /dev/null +++ b/src/suggester/suggester.ts @@ -0,0 +1,241 @@ +import { + App, + type FuzzyMatch, + FuzzySuggestModal, + Scope, + SuggestModal +} from "obsidian"; +import { createPopper, type Instance as PopperInstance } from "@popperjs/core"; +declare module "obsidian" { + interface App { + keymap: { + pushScope(scope: Scope): void; + popScope(scope: Scope): void; + }; + } +} +declare global { + var app: App; +} +class Suggester { + owner: SuggestModal; + items: T[]; + suggestions: HTMLElement[]; + selectedItem: number; + containerEl: HTMLElement; + constructor( + owner: SuggestModal, + containerEl: HTMLElement, + scope: Scope + ) { + this.containerEl = containerEl; + this.owner = owner; + containerEl.on( + "click", + ".suggestion-item", + this.onSuggestionClick.bind(this) + ); + containerEl.on( + "mousemove", + ".suggestion-item", + this.onSuggestionMouseover.bind(this) + ); + + scope.register([], "ArrowUp", () => { + this.setSelectedItem(this.selectedItem - 1, true); + return false; + }); + + scope.register([], "ArrowDown", () => { + this.setSelectedItem(this.selectedItem + 1, true); + return false; + }); + + scope.register([], "Enter", (evt) => { + this.useSelectedItem(evt); + return false; + }); + + scope.register([], "Tab", (evt) => { + this.chooseSuggestion(evt); + return false; + }); + } + chooseSuggestion(evt: KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.onChooseSuggestion(currentValue, evt); + } + } + onSuggestionClick(event: MouseEvent, el: HTMLElement): void { + event.preventDefault(); + if (!this.suggestions || !this.suggestions.length) return; + + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + this.useSelectedItem(event); + } + + onSuggestionMouseover(event: MouseEvent, el: HTMLElement): void { + if (!this.suggestions || !this.suggestions.length) return; + const item = this.suggestions.indexOf(el); + this.setSelectedItem(item, false); + } + empty() { + this.containerEl.empty(); + } + setSuggestions(items: T[]) { + this.containerEl.empty(); + const els: HTMLDivElement[] = []; + + items.forEach((item) => { + const suggestionEl = this.containerEl.createDiv("suggestion-item"); + this.owner.renderSuggestion(item, suggestionEl); + els.push(suggestionEl); + }); + this.items = items; + this.suggestions = els; + this.setSelectedItem(0, false); + } + useSelectedItem(event: MouseEvent | KeyboardEvent) { + if (!this.items || !this.items.length) return; + const currentValue = this.items[this.selectedItem]; + if (currentValue) { + this.owner.selectSuggestion(currentValue, event); + } + } + wrap(value: number, size: number): number { + return ((value % size) + size) % size; + } + setSelectedItem(index: number, scroll: boolean) { + const nIndex = this.wrap(index, this.suggestions.length); + const prev = this.suggestions[this.selectedItem]; + const next = this.suggestions[nIndex]; + + if (prev) prev.removeClass("is-selected"); + if (next) next.addClass("is-selected"); + + this.selectedItem = nIndex; + + if (scroll) { + next.scrollIntoView(false); + } + } +} + +export abstract class SuggestionModal extends FuzzySuggestModal { + items: T[] = []; + item: T; + suggestions: HTMLDivElement[]; + popper: PopperInstance; + scope: Scope = new Scope(); + suggester: Suggester>; + suggestEl: HTMLDivElement; + promptEl: HTMLDivElement; + emptyStateText: string = "No match found"; + limit: number = 100; + shouldNotOpen: boolean; + constructor(app: App, inputEl: HTMLInputElement, items: T[]) { + super(app); + this.shouldNotOpen = !this.items.length; + this.inputEl = inputEl; + this.items = items; + + this.suggestEl = createDiv("suggestion-container"); + + this.contentEl = this.suggestEl.createDiv("suggestion"); + + this.suggester = new Suggester(this, this.contentEl, this.scope); + + this.scope.register([], "Escape", this.onEscape.bind(this)); + + this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); + this.inputEl.addEventListener("focus", this.onFocus.bind(this)); + this.inputEl.addEventListener("blur", this.close.bind(this)); + this.suggestEl.on( + "mousedown", + ".suggestion-container", + (event: MouseEvent) => { + event.preventDefault(); + } + ); + } + empty() { + this.suggester.empty(); + } + onInputChanged(): void { + if (this.shouldNotOpen) return; + const inputStr = this.modifyInput(this.inputEl.value); + const suggestions = this.getSuggestions(inputStr ?? ""); + if (suggestions.length > 0) { + this.suggester.setSuggestions(suggestions.slice(0, this.limit)); + } else { + this.onNoSuggestion(); + } + this.open(); + } + onFocus(): void { + this.shouldNotOpen = false; + this.onInputChanged(); + } + modifyInput(input: string): string | undefined { + return input; + } + onNoSuggestion() { + this.empty(); + } + open(): void { + // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin + this.app.keymap.pushScope(this.scope); + + document.body.appendChild(this.suggestEl); + this.popper = createPopper(this.inputEl, this.suggestEl, { + placement: "bottom-start", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 10] + } + }, + { + name: "flip", + options: { + fallbackPlacements: ["top"] + } + } + ] + }); + } + + onEscape(): void { + this.close(); + this.shouldNotOpen = true; + } + close(): void { + // TODO: Figure out a better way to do this. Idea from Periodic Notes plugin + this.app.keymap.popScope(this.scope); + + this.suggester.setSuggestions([]); + if (this.popper) { + this.popper.destroy(); + } + + this.suggestEl.detach(); + } + createPrompt(prompts: HTMLSpanElement[]) { + if (!this.promptEl) + this.promptEl = this.suggestEl.createDiv("prompt-instructions"); + let prompt = this.promptEl.createDiv("prompt-instruction"); + for (let p of prompts) { + prompt.appendChild(p); + } + } + abstract onChooseItem(item: T, evt: MouseEvent | KeyboardEvent): void; + abstract getItemText(arg: T): string; + + getItems() { + return this.items; + } +} diff --git a/test/parser/lexer.test.js b/test/parser/lexer.test.js new file mode 100644 index 0000000..9002388 --- /dev/null +++ b/test/parser/lexer.test.js @@ -0,0 +1,403 @@ +import { expect, test } from 'vitest' +import Lexer from 'src/parser/lexer' +import { toLexicalToken } from 'test/util' + +/** + * possible formats: + * d == 1d100 or whats set as defaults in settings + * XdX + * + * Xd% == Xd100 + * XdXXXXX% custom percent faces per digit + * + * XdXdh{n} drop highest n rolls; default n == 1 + * XdXdl{n} drop lowest n rolls; default n == 1 + * XdXk{n} / XdXkh{n} keep highest n rolls; default n == 1 + * XdXkl{n} keep lowest n rolls; default n == 1 + * + * XdX!!{n|i} explode and combine explodedresults => i is special and equals n == 100 (infinite) + * XdX!{n|i} explode and list exploded results in array => i is special and equals n == 100 (infinite) + * + * XdXu all rolls must be unique, rerolled dice are render with prepended u + * + * Xd[Y, Z] rolls a numberbetween Y and Z + * + * XdXr{n|i} reroll minimum dice, e.g. every 1 will be rerolled n times max or i (100 times) + * + * XdXsa / XdXsd sort results ascending/descending + * --- + * XdF roll fudge/fate dice (cannot be used in calculations) + * 1dS roll a Fantasy AGE stunt dice + * --- + * [[Note]] random block of note + * Xd[[]] X random block from Note + * [[Note]]|line random line from Note + * [[Note]]|heading-Y random heading of size Y + * [[Note^block-id]] listitem or row of table + * 1d4+1[[Note^block-id]]` 1d4+1 listitems or rows of table + * [[Note^block-id]]|Header 2 select Header 2 column for results in tables + * + * TODO: add tests + * - https://plugins.javalent.com/dice/rollers/section#Block+types tests + * - adding '|xy' to the end of a table roller will return a random table cell (not row) + */ + +test('lexer should parse "d"', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("d").map(toLexicalToken) + expect(actual).toEqual([{ conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d100' }]) +}) + +test('lexer should parse "5d17"', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d17").map(toLexicalToken) + expect(actual).toEqual([{ conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d17' }]) +}) + +test('lexer should parse "5d%"', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d%").map(toLexicalToken) + expect(actual).toEqual([{ conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d%' }]) +}) + +test('lexer should parse "2d66%"', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("2d66%").map(toLexicalToken) + expect(actual).toEqual([{ conditions: undefined, parenedDice: undefined, type: '%', value: '2d66%' }]) +}) + +test('lexer should parse "1d6dh', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6dh").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'dh', value: '' } + ]) +}) + +test('lexer should parse "1d6dh3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6dh3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'dh', value: '3' } + ]) +}) + +test('lexer should parse "1d6dl', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6dl").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'dl', value: '' } + ]) +}) + +test('lexer should parse "1d6dl3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6dl3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'dl', value: '3' } + ]) +}) + +test('lexer should parse "1d6kl', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6kl").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kl', value: '' } + ]) +}) + +test('lexer should parse "1d6kl3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6kl3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kl', value: '3' } + ]) +}) + +test('lexer should parse "1d6k', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6k").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kh', value: '' } + ]) +}) + +test('lexer should parse "1d6k3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6k3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kh', value: '3' } + ]) +}) + +test('lexer should parse "1d6kh', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6kh").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kh', value: '' } + ]) +}) + +test('lexer should parse "1d6kh3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d6kh3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '1d6' }, + { conditions: undefined, parenedDice: undefined, type: 'kh', value: '3' } + ]) +}) + +test('lexer should parse "5d6!!', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!!").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!!', value: '' } + ]) +}) + +test('lexer should parse "5d6!!3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!!3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!!', value: '3' } + ]) +}) + +test('lexer should parse "5d6!!i', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!!i").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!!', value: '100' } + ]) +}) + + +test('lexer should parse "5d6!', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!', value: '' } + ]) +}) + +test('lexer should parse "5d6!3', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!3").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!', value: '3' } + ]) +}) + +test('lexer should parse "5d6!i', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6!i").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: '!', value: '100' } + ]) +}) + +test('lexer should parse "5d6u', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d6u").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d6' }, + { conditions: undefined, parenedDice: undefined, type: 'u', value: 'u' } + ]) +}) + + +test('lexer should parse "5d[3,7]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d[3,7]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d[3,7]' } + ]) +}) + +test('lexer should parse "5d[7,3]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d[7,3]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d[7,3]' } + ]) +}) + +test('lexer should parse "5d7r', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7r").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'r', value: '' } + ]) +}) + +test('lexer should parse "5d7r4', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7r4").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'r', value: '4' } + ]) +}) + +test('lexer should parse "5d7ri', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7ri").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'r', value: '100' } + ]) +}) + +test('lexer should parse "5d7s', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7s").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'sort', value: 'sa' } + ]) +}) + +test('lexer should parse "5d7sa', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7sa").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'sort', value: 'sa' } + ]) +}) + +test('lexer should parse "5d7sd', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("5d7sd").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '5d7' }, + { conditions: undefined, parenedDice: undefined, type: 'sort', value: 'sd' } + ]) +}) + +test('lexer should parse "6dF', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("6dF").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'dice', value: '6dF' } + ]) +}) + +// SPECIAL DICE =============================================================== + +test('lexer should parse "1dS', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1dS").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'stunt', value: '1dS' } + ]) +}) + +test('lexer should parse "5dS', () => { + let lexer = new Lexer(1, 100); + expect(() => lexer.parse("5dS")).toThrow() +}) + +// BLOCKS ===================================================================== + +test('lexer should parse "[[Note]]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("[[Note]]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'section', value: '[[Note]]' } + ]) +}) + +test('lexer should parse "4d[[Note]]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("4d[[Note]]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'section', value: '4d[[Note]]' } + ]) +}) + +test('lexer should parse "[[Note]]|line', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("[[Note]]|line").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'line', value: '[[Note]]|line' } + ]) +}) + +test('lexer should parse "[[Note]]|heading-2', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("[[Note]]|heading-2").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'section', value: '[[Note]]|heading-2' } + ]) +}) + +test('lexer should parse "[[Note^block-id]]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("[[Note^block-id]]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'table', value: '[[Note^block-id]]' } + ]) +}) + +test('lexer should parse "1d4+1[[Note^block-id]]', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("1d4+1[[Note^block-id]]").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'table', value: '1d4+1[[Note^block-id]]' } + ]) +}) + +test('lexer should parse "[[Note^block-id]]|Header 2', () => { + let lexer = new Lexer(1, 100); + let actual = lexer.parse("[[Note^block-id]]|Header 2").map(toLexicalToken) + expect(actual).toEqual([ + { conditions: undefined, parenedDice: undefined, type: 'table', value: '[[Note^block-id]]|Header 2' } + ]) +}) + +// FORMULAS =================================================================== + +test('lexer should parse "1d20 + -2" like "1d20 - 2"', () => { + let lexer = new Lexer(1, 100); + let a = lexer.parse("1d20 + -2").map(toLexicalToken) + let b = lexer.parse("1d20 - 2").map(toLexicalToken) + expect(a).toEqual(b); +}) + +test('lexer should parse "1d20 - -2" like "1d20 + 2"', () => { + let lexer = new Lexer(1, 100); + let a = lexer.parse("1d20 - -2").map(toLexicalToken) + let b = lexer.parse("1d20 + 2").map(toLexicalToken) + expect(a).toEqual(b); +}) + +test('lexer should parse "1d20 ----2" like "1d20 + 2"', () => { + let lexer = new Lexer(1, 100); + let a = lexer.parse("1d20 ----2").map(toLexicalToken) + let b = lexer.parse("1d20 + 2").map(toLexicalToken) + expect(a).toEqual(b); +}) + +test('lexer should parse "1d20 -++---+-+--++17" like "1d20 - 17"', () => { + let lexer = new Lexer(1, 100); + let a = lexer.parse("1d20 -++---+-+--++17").map(toLexicalToken) + let b = lexer.parse("1d20 - 17").map(toLexicalToken) + expect(a).toEqual(b); +}) \ No newline at end of file diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 0000000..3989a73 --- /dev/null +++ b/test/util.ts @@ -0,0 +1,4 @@ +export function toLexicalToken(e: LexicalToken): LexicalToken { + let { conditions, parenedDice, type, value } = e; + return { conditions, parenedDice, type, value }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5cd8bd9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + watch: false + }, +}) \ No newline at end of file