1719 lines
58 KiB
TypeScript
1719 lines
58 KiB
TypeScript
import { QuartzPluginData } from "../../../plugins/vfile"
|
|
import { FilePath, FullSlug, simplifySlug, slugifyFilePath, splitAnchor } from "../../path"
|
|
import { parseWikilink, resolveWikilinkTarget } from "../../wikilinks"
|
|
import { BinaryExpr, Literal, Span } from "./ast"
|
|
import { BaseExpressionDiagnostic } from "./diagnostics"
|
|
import { ProgramIR, Instruction } from "./ir"
|
|
|
|
export type NullValue = { kind: "null" }
|
|
export type BooleanValue = { kind: "boolean"; value: boolean }
|
|
export type NumberValue = { kind: "number"; value: number }
|
|
export type StringValue = { kind: "string"; value: string }
|
|
export type DateValue = { kind: "date"; value: Date }
|
|
export type DurationValue = { kind: "duration"; value: number; months: number }
|
|
export type ListValue = { kind: "list"; value: Value[] }
|
|
export type ObjectValue = { kind: "object"; value: Record<string, Value> }
|
|
export type FileValue = { kind: "file"; value: QuartzPluginData }
|
|
export type LinkValue = { kind: "link"; value: string; display?: string }
|
|
export type RegexValue = { kind: "regex"; value: RegExp }
|
|
export type HtmlValue = { kind: "html"; value: string }
|
|
export type IconValue = { kind: "icon"; value: string }
|
|
export type ImageValue = { kind: "image"; value: string }
|
|
|
|
export type Value =
|
|
| NullValue
|
|
| BooleanValue
|
|
| NumberValue
|
|
| StringValue
|
|
| DateValue
|
|
| DurationValue
|
|
| ListValue
|
|
| ObjectValue
|
|
| FileValue
|
|
| LinkValue
|
|
| RegexValue
|
|
| HtmlValue
|
|
| IconValue
|
|
| ImageValue
|
|
|
|
export type ValueKind = Value["kind"]
|
|
export type ValueOf<K extends ValueKind> = Extract<Value, { kind: K }>
|
|
|
|
export function isValueKind(value: Value, kind: "null"): value is NullValue
|
|
export function isValueKind(value: Value, kind: "boolean"): value is BooleanValue
|
|
export function isValueKind(value: Value, kind: "number"): value is NumberValue
|
|
export function isValueKind(value: Value, kind: "string"): value is StringValue
|
|
export function isValueKind(value: Value, kind: "date"): value is DateValue
|
|
export function isValueKind(value: Value, kind: "duration"): value is DurationValue
|
|
export function isValueKind(value: Value, kind: "list"): value is ListValue
|
|
export function isValueKind(value: Value, kind: "object"): value is ObjectValue
|
|
export function isValueKind(value: Value, kind: "file"): value is FileValue
|
|
export function isValueKind(value: Value, kind: "link"): value is LinkValue
|
|
export function isValueKind(value: Value, kind: "regex"): value is RegexValue
|
|
export function isValueKind(value: Value, kind: "html"): value is HtmlValue
|
|
export function isValueKind(value: Value, kind: "icon"): value is IconValue
|
|
export function isValueKind(value: Value, kind: "image"): value is ImageValue
|
|
export function isValueKind(value: Value, kind: ValueKind): value is Value {
|
|
return value.kind === kind
|
|
}
|
|
|
|
export type EvalContext = {
|
|
file: QuartzPluginData
|
|
thisFile?: QuartzPluginData
|
|
allFiles: QuartzPluginData[]
|
|
rows?: QuartzPluginData[]
|
|
fileIndex?: Map<string, QuartzPluginData>
|
|
backlinksIndex?: Map<string, string[]>
|
|
formulas?: Record<string, ProgramIR>
|
|
formulaSources?: Record<string, string>
|
|
formulaCache?: Map<string, Value>
|
|
formulaStack?: Set<string>
|
|
locals?: Record<string, Value>
|
|
values?: Value[]
|
|
diagnostics?: BaseExpressionDiagnostic[]
|
|
diagnosticContext?: string
|
|
diagnosticSource?: string
|
|
diagnosticSet?: Set<string>
|
|
propertyCache?: Map<string, Value>
|
|
}
|
|
|
|
const nullValue: NullValue = { kind: "null" }
|
|
|
|
const makeNull = (): NullValue => nullValue
|
|
const makeBoolean = (value: boolean): BooleanValue => ({ kind: "boolean", value })
|
|
const makeNumber = (value: number): NumberValue => ({ kind: "number", value })
|
|
const makeString = (value: string): StringValue => ({ kind: "string", value })
|
|
const makeDate = (value: Date): DateValue => ({ kind: "date", value })
|
|
const makeDuration = (value: number, months = 0): DurationValue => ({
|
|
kind: "duration",
|
|
value,
|
|
months,
|
|
})
|
|
const makeList = (value: Value[]): ListValue => ({ kind: "list", value })
|
|
const makeObject = (value: Record<string, Value>): ObjectValue => ({ kind: "object", value })
|
|
const makeFile = (value: QuartzPluginData): FileValue => ({ kind: "file", value })
|
|
const makeLink = (value: string, display?: string): LinkValue => ({ kind: "link", value, display })
|
|
const makeRegex = (value: RegExp): RegexValue => ({ kind: "regex", value })
|
|
const makeHtml = (value: string): HtmlValue => ({ kind: "html", value })
|
|
const makeIcon = (value: string): IconValue => ({ kind: "icon", value })
|
|
const makeImage = (value: string): ImageValue => ({ kind: "image", value })
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
|
|
const isValue = (value: unknown): value is Value =>
|
|
typeof value === "object" && value !== null && "kind" in value
|
|
|
|
const isNumberValue = (value: Value): value is NumberValue => isValueKind(value, "number")
|
|
|
|
const isStringValue = (value: Value): value is StringValue => isValueKind(value, "string")
|
|
|
|
const isBooleanValue = (value: Value): value is BooleanValue => isValueKind(value, "boolean")
|
|
|
|
const isListValue = (value: Value): value is ListValue => isValueKind(value, "list")
|
|
|
|
const isObjectValue = (value: Value): value is ObjectValue => isValueKind(value, "object")
|
|
|
|
const isDateValue = (value: Value): value is DateValue => isValueKind(value, "date")
|
|
|
|
const isDurationValue = (value: Value): value is DurationValue => isValueKind(value, "duration")
|
|
|
|
const isFileValue = (value: Value): value is FileValue => isValueKind(value, "file")
|
|
|
|
const isLinkValue = (value: Value): value is LinkValue => isValueKind(value, "link")
|
|
|
|
const isRegexValue = (value: Value): value is RegexValue => isValueKind(value, "regex")
|
|
|
|
const stringMethods = new Set([
|
|
"contains",
|
|
"containsAny",
|
|
"containsAll",
|
|
"startsWith",
|
|
"endsWith",
|
|
"isEmpty",
|
|
"lower",
|
|
"title",
|
|
"trim",
|
|
"replace",
|
|
"repeat",
|
|
"reverse",
|
|
"slice",
|
|
"split",
|
|
"length",
|
|
])
|
|
|
|
const numberMethods = new Set(["abs", "ceil", "floor", "round", "toFixed", "isEmpty"])
|
|
|
|
const listMethods = new Set([
|
|
"contains",
|
|
"containsAny",
|
|
"containsAll",
|
|
"flat",
|
|
"join",
|
|
"reverse",
|
|
"slice",
|
|
"sort",
|
|
"unique",
|
|
"isEmpty",
|
|
"length",
|
|
"sum",
|
|
"mean",
|
|
"average",
|
|
"median",
|
|
"stddev",
|
|
"min",
|
|
"max",
|
|
])
|
|
|
|
const dateMethods = new Set([
|
|
"date",
|
|
"format",
|
|
"time",
|
|
"relative",
|
|
"isEmpty",
|
|
"year",
|
|
"month",
|
|
"day",
|
|
"hour",
|
|
"minute",
|
|
"second",
|
|
"millisecond",
|
|
])
|
|
|
|
const fileMethods = new Set(["asLink", "hasTag", "inFolder", "hasProperty", "hasLink"])
|
|
|
|
const linkMethods = new Set(["asFile", "linksTo"])
|
|
|
|
const objectMethods = new Set(["isEmpty", "keys", "values"])
|
|
|
|
const formatLinkValue = (value: LinkValue): string => {
|
|
const target = value.value
|
|
const display = value.display?.trim()
|
|
if (display && display.length > 0) {
|
|
return `[[${target}|${display}]]`
|
|
}
|
|
return `[[${target}]]`
|
|
}
|
|
|
|
const valueToString = (value: Value): string => {
|
|
switch (value.kind) {
|
|
case "null":
|
|
return ""
|
|
case "boolean":
|
|
return value.value ? "true" : "false"
|
|
case "number":
|
|
return Number.isFinite(value.value) ? String(value.value) : ""
|
|
case "string":
|
|
return value.value
|
|
case "date":
|
|
return formatDate(value.value)
|
|
case "duration":
|
|
return String(durationToMs(value))
|
|
case "list":
|
|
return value.value.map(valueToString).join(", ")
|
|
case "object":
|
|
return Object.keys(value.value).length > 0 ? "[object]" : ""
|
|
case "file":
|
|
return value.value.slug ? String(value.value.slug) : ""
|
|
case "link":
|
|
return value.value
|
|
case "regex":
|
|
return value.value.source
|
|
case "html":
|
|
return value.value
|
|
case "icon":
|
|
return value.value
|
|
case "image":
|
|
return value.value
|
|
}
|
|
}
|
|
|
|
const valueToNumber = (value: Value): number => {
|
|
switch (value.kind) {
|
|
case "number":
|
|
return value.value
|
|
case "duration":
|
|
return durationToMs(value)
|
|
case "boolean":
|
|
return value.value ? 1 : 0
|
|
case "string": {
|
|
const num = Number(value.value)
|
|
return Number.isFinite(num) ? num : Number.NaN
|
|
}
|
|
case "date":
|
|
return value.value.getTime()
|
|
default:
|
|
return Number.NaN
|
|
}
|
|
}
|
|
|
|
const coerceToNumber = (value: Value): number | null => {
|
|
const num = valueToNumber(value)
|
|
return Number.isFinite(num) ? num : null
|
|
}
|
|
|
|
const coerceToDate = (value: Value): Date | null => {
|
|
if (isDateValue(value)) return value.value
|
|
if (isNumberValue(value)) {
|
|
const date = new Date(value.value)
|
|
return Number.isNaN(date.getTime()) ? null : date
|
|
}
|
|
if (isStringValue(value)) {
|
|
const parsed = new Date(value.value)
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
}
|
|
return null
|
|
}
|
|
|
|
const isScalarValue = (value: Value): boolean =>
|
|
!(
|
|
value.kind === "list" ||
|
|
value.kind === "object" ||
|
|
value.kind === "file" ||
|
|
value.kind === "link" ||
|
|
value.kind === "regex" ||
|
|
value.kind === "html" ||
|
|
value.kind === "icon" ||
|
|
value.kind === "image"
|
|
)
|
|
|
|
const valueToBoolean = (value: Value): boolean => {
|
|
switch (value.kind) {
|
|
case "null":
|
|
return false
|
|
case "boolean":
|
|
return value.value
|
|
case "number":
|
|
return Number.isFinite(value.value) && value.value !== 0
|
|
case "string":
|
|
return value.value.length > 0
|
|
case "date":
|
|
return true
|
|
case "duration":
|
|
return durationToMs(value) !== 0
|
|
case "list":
|
|
return value.value.length > 0
|
|
case "object":
|
|
return Object.keys(value.value).length > 0
|
|
case "file":
|
|
return true
|
|
case "link":
|
|
return value.value.length > 0
|
|
case "regex":
|
|
return true
|
|
case "html":
|
|
return value.value.length > 0
|
|
case "icon":
|
|
return value.value.length > 0
|
|
case "image":
|
|
return value.value.length > 0
|
|
}
|
|
}
|
|
|
|
const valueEquals = (left: Value, right: Value, ctx: EvalContext): boolean => {
|
|
const leftLinkish = isLinkValue(left) || isFileValue(left)
|
|
const rightLinkish = isLinkValue(right) || isFileValue(right)
|
|
if (
|
|
(leftLinkish && (rightLinkish || isStringValue(right))) ||
|
|
(rightLinkish && isStringValue(left))
|
|
) {
|
|
const leftKey = resolveLinkComparisonKey(left, ctx)
|
|
const rightKey = resolveLinkComparisonKey(right, ctx)
|
|
if (leftKey.slug && rightKey.slug) return leftKey.slug === rightKey.slug
|
|
if (!leftKey.slug && !rightKey.slug) return leftKey.text === rightKey.text
|
|
return false
|
|
}
|
|
if (left.kind !== right.kind) {
|
|
if (isScalarValue(left) && isScalarValue(right)) {
|
|
const leftDate = coerceToDate(left)
|
|
const rightDate = coerceToDate(right)
|
|
if (leftDate && rightDate) return leftDate.getTime() === rightDate.getTime()
|
|
const leftNum = coerceToNumber(left)
|
|
const rightNum = coerceToNumber(right)
|
|
if (leftNum !== null && rightNum !== null) return leftNum === rightNum
|
|
return valueToString(left) === valueToString(right)
|
|
}
|
|
return false
|
|
}
|
|
if (left.kind === "null") return true
|
|
if (isBooleanValue(left) && isBooleanValue(right)) return left.value === right.value
|
|
if (isNumberValue(left) && isNumberValue(right)) return left.value === right.value
|
|
if (isStringValue(left) && isStringValue(right)) return left.value === right.value
|
|
if (isDateValue(left) && isDateValue(right)) return left.value.getTime() === right.value.getTime()
|
|
if (isDurationValue(left) && isDurationValue(right)) {
|
|
return left.value === right.value && left.months === right.months
|
|
}
|
|
if (isRegexValue(left) && isRegexValue(right)) return left.value.source === right.value.source
|
|
if (isListValue(left) && isListValue(right)) {
|
|
if (left.value.length !== right.value.length) return false
|
|
for (let i = 0; i < left.value.length; i += 1) {
|
|
if (!valueEquals(left.value[i], right.value[i], ctx)) return false
|
|
}
|
|
return true
|
|
}
|
|
if (isObjectValue(left) && isObjectValue(right)) {
|
|
const leftKeys = Object.keys(left.value)
|
|
const rightKeys = Object.keys(right.value)
|
|
if (leftKeys.length !== rightKeys.length) return false
|
|
for (const key of leftKeys) {
|
|
const l = left.value[key]
|
|
const r = right.value[key]
|
|
if (!r || !valueEquals(l, r, ctx)) return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const formatDate = (date: Date): string => {
|
|
const year = String(date.getUTCFullYear()).padStart(4, "0")
|
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0")
|
|
const day = String(date.getUTCDate()).padStart(2, "0")
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
const formatTime = (date: Date): string => {
|
|
const hour = String(date.getUTCHours()).padStart(2, "0")
|
|
const minute = String(date.getUTCMinutes()).padStart(2, "0")
|
|
const second = String(date.getUTCSeconds()).padStart(2, "0")
|
|
return `${hour}:${minute}:${second}`
|
|
}
|
|
|
|
const formatDatePattern = (date: Date, pattern: string): string => {
|
|
const replacements: Record<string, string> = {
|
|
YYYY: String(date.getUTCFullYear()).padStart(4, "0"),
|
|
YY: String(date.getUTCFullYear() % 100).padStart(2, "0"),
|
|
MM: String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
DD: String(date.getUTCDate()).padStart(2, "0"),
|
|
HH: String(date.getUTCHours()).padStart(2, "0"),
|
|
mm: String(date.getUTCMinutes()).padStart(2, "0"),
|
|
ss: String(date.getUTCSeconds()).padStart(2, "0"),
|
|
SSS: String(date.getUTCMilliseconds()).padStart(3, "0"),
|
|
}
|
|
let result = pattern
|
|
for (const [token, replacement] of Object.entries(replacements)) {
|
|
result = result.split(token).join(replacement)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const formatRelative = (date: Date): string => {
|
|
const now = Date.now()
|
|
const diff = date.getTime() - now
|
|
const abs = Math.abs(diff)
|
|
const seconds = Math.round(abs / 1000)
|
|
const minutes = Math.round(abs / 60000)
|
|
const hours = Math.round(abs / 3600000)
|
|
const days = Math.round(abs / 86400000)
|
|
const weeks = Math.round(abs / 604800000)
|
|
const direction = diff < 0 ? "ago" : "from now"
|
|
if (seconds < 60) return `${seconds}s ${direction}`
|
|
if (minutes < 60) return `${minutes}m ${direction}`
|
|
if (hours < 24) return `${hours}h ${direction}`
|
|
if (days < 7) return `${days}d ${direction}`
|
|
return `${weeks}w ${direction}`
|
|
}
|
|
|
|
const durationToMs = (duration: DurationValue): number =>
|
|
duration.value + duration.months * 30 * 24 * 60 * 60 * 1000
|
|
|
|
const parseDurationParts = (input: string): { months: number; ms: number } => {
|
|
const trimmed = input.trim()
|
|
const asNumber = Number(trimmed)
|
|
if (!isNaN(asNumber)) {
|
|
return { months: 0, ms: asNumber }
|
|
}
|
|
|
|
let months = 0
|
|
let ms = 0
|
|
const regex = /(\d+(?:\.\d+)?)\s*([a-zA-Z]+)/g
|
|
let match
|
|
while ((match = regex.exec(trimmed)) !== null) {
|
|
const value = parseFloat(match[1])
|
|
const unitRaw = match[2]
|
|
const unit = unitRaw.toLowerCase()
|
|
if (unitRaw === "M" || unit === "mo" || unit === "month" || unit === "months") {
|
|
months += value
|
|
continue
|
|
}
|
|
if (unit === "y" || unit === "yr" || unit === "yrs" || unit === "year" || unit === "years") {
|
|
months += value * 12
|
|
continue
|
|
}
|
|
if (unit === "ms" || unit === "millisecond" || unit === "milliseconds") {
|
|
ms += value
|
|
continue
|
|
}
|
|
if (
|
|
unit === "s" ||
|
|
unit === "sec" ||
|
|
unit === "secs" ||
|
|
unit === "second" ||
|
|
unit === "seconds"
|
|
) {
|
|
ms += value * 1000
|
|
continue
|
|
}
|
|
if (
|
|
unit === "m" ||
|
|
unit === "min" ||
|
|
unit === "mins" ||
|
|
unit === "minute" ||
|
|
unit === "minutes"
|
|
) {
|
|
ms += value * 60 * 1000
|
|
continue
|
|
}
|
|
if (unit === "h" || unit === "hr" || unit === "hrs" || unit === "hour" || unit === "hours") {
|
|
ms += value * 60 * 60 * 1000
|
|
continue
|
|
}
|
|
if (unit === "d" || unit === "day" || unit === "days") {
|
|
ms += value * 24 * 60 * 60 * 1000
|
|
continue
|
|
}
|
|
if (unit === "w" || unit === "week" || unit === "weeks") {
|
|
ms += value * 7 * 24 * 60 * 60 * 1000
|
|
continue
|
|
}
|
|
}
|
|
|
|
return { months, ms }
|
|
}
|
|
|
|
const addDurationToDate = (date: Date, duration: DurationValue, direction: 1 | -1): Date => {
|
|
const result = new Date(date.getTime())
|
|
if (duration.months !== 0) {
|
|
const totalMonths = duration.months * direction
|
|
const wholeMonths = Math.trunc(totalMonths)
|
|
const fractional = totalMonths - wholeMonths
|
|
if (wholeMonths !== 0) {
|
|
result.setUTCMonth(result.getUTCMonth() + wholeMonths)
|
|
}
|
|
if (fractional !== 0) {
|
|
result.setTime(result.getTime() + fractional * 30 * 24 * 60 * 60 * 1000)
|
|
}
|
|
}
|
|
if (duration.value !== 0) {
|
|
result.setTime(result.getTime() + direction * duration.value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const pushRuntimeDiagnostic = (ctx: EvalContext, message: string, span: Span) => {
|
|
if (!ctx.diagnostics || !ctx.diagnosticContext) return
|
|
const source = ctx.diagnosticSource ?? ""
|
|
const key = `${ctx.diagnosticContext}|${message}|${span.start.offset}|${span.end.offset}|${source}`
|
|
if (ctx.diagnosticSet) {
|
|
if (ctx.diagnosticSet.has(key)) return
|
|
ctx.diagnosticSet.add(key)
|
|
}
|
|
ctx.diagnostics.push({ kind: "runtime", message, span, context: ctx.diagnosticContext, source })
|
|
}
|
|
|
|
const parseDurationValue = (raw: Value): DurationValue | null => {
|
|
if (isDurationValue(raw)) return raw
|
|
if (isNumberValue(raw)) return makeDuration(raw.value)
|
|
if (isStringValue(raw)) {
|
|
const parsed = parseDurationParts(raw.value)
|
|
return makeDuration(parsed.ms, parsed.months)
|
|
}
|
|
return null
|
|
}
|
|
|
|
const toValue = (input: unknown): Value => {
|
|
if (input === null || input === undefined) return makeNull()
|
|
if (typeof input === "boolean") return makeBoolean(input)
|
|
if (typeof input === "number") return makeNumber(input)
|
|
if (typeof input === "string") {
|
|
const trimmed = input.trim()
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
|
|
const parsed = new Date(trimmed)
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
return makeDate(parsed)
|
|
}
|
|
}
|
|
return makeString(input)
|
|
}
|
|
if (input instanceof Date) return makeDate(input)
|
|
if (input instanceof RegExp) return makeRegex(input)
|
|
if (Array.isArray(input)) return makeList(input.map(toValue))
|
|
if (isRecord(input)) {
|
|
const obj: Record<string, Value> = {}
|
|
for (const [key, value] of Object.entries(input)) {
|
|
obj[key] = toValue(value)
|
|
}
|
|
return makeObject(obj)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
export const valueToUnknown = (value: Value): unknown => {
|
|
switch (value.kind) {
|
|
case "null":
|
|
return undefined
|
|
case "boolean":
|
|
return value.value
|
|
case "number":
|
|
return value.value
|
|
case "string":
|
|
return value.value
|
|
case "date":
|
|
return value.value
|
|
case "duration":
|
|
return durationToMs(value)
|
|
case "list":
|
|
return value.value.map(valueToUnknown)
|
|
case "object": {
|
|
const obj: Record<string, unknown> = {}
|
|
for (const [key, entry] of Object.entries(value.value)) {
|
|
obj[key] = valueToUnknown(entry)
|
|
}
|
|
return obj
|
|
}
|
|
case "file":
|
|
return value.value
|
|
case "link":
|
|
return formatLinkValue(value)
|
|
case "regex":
|
|
return value.value
|
|
case "html":
|
|
return value.value
|
|
case "icon":
|
|
return value.value
|
|
case "image":
|
|
return value.value
|
|
}
|
|
}
|
|
|
|
const literalToValue = (expr: Literal): Value => {
|
|
if (expr.kind === "number") return makeNumber(expr.value)
|
|
if (expr.kind === "string") return makeString(expr.value)
|
|
if (expr.kind === "boolean") return makeBoolean(expr.value)
|
|
if (expr.kind === "null") return makeNull()
|
|
if (expr.kind === "date") return makeDate(new Date(expr.value))
|
|
if (expr.kind === "duration") {
|
|
const parsed = parseDurationParts(expr.value)
|
|
return makeDuration(parsed.ms, parsed.months)
|
|
}
|
|
if (expr.kind === "regex") {
|
|
const regex = new RegExp(expr.value, expr.flags)
|
|
return makeRegex(regex)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const evaluateProgram = (program: ProgramIR, ctx: EvalContext): Value => {
|
|
const stack: Value[] = []
|
|
const instructions = program.instructions
|
|
let ip = 0
|
|
|
|
const popValue = (): Value => stack.pop() ?? makeNull()
|
|
const popArgs = (count: number): Value[] => {
|
|
if (count <= 0) return []
|
|
const start = Math.max(0, stack.length - count)
|
|
return stack.splice(start, stack.length - start)
|
|
}
|
|
|
|
while (ip < instructions.length) {
|
|
const instr = instructions[ip] as Instruction
|
|
switch (instr.op) {
|
|
case "const":
|
|
stack.push(literalToValue(instr.literal))
|
|
break
|
|
case "ident":
|
|
stack.push(resolveIdentifier(instr.name, ctx))
|
|
break
|
|
case "load_formula":
|
|
stack.push(resolveFormulaProperty(instr.name, ctx))
|
|
break
|
|
case "load_formula_index": {
|
|
const indexValue = popValue()
|
|
if (isStringValue(indexValue)) {
|
|
stack.push(resolveFormulaProperty(indexValue.value, ctx))
|
|
} else {
|
|
stack.push(makeNull())
|
|
}
|
|
break
|
|
}
|
|
case "member": {
|
|
const objectValue = popValue()
|
|
stack.push(accessProperty(objectValue, instr.property, ctx))
|
|
break
|
|
}
|
|
case "index": {
|
|
const indexValue = popValue()
|
|
const objectValue = popValue()
|
|
stack.push(accessIndex(objectValue, indexValue))
|
|
break
|
|
}
|
|
case "list": {
|
|
const count = Math.max(0, instr.count)
|
|
const items = count > 0 ? stack.splice(stack.length - count, count) : []
|
|
stack.push(makeList(items))
|
|
break
|
|
}
|
|
case "unary": {
|
|
const value = popValue()
|
|
stack.push(applyUnary(instr.operator, value))
|
|
break
|
|
}
|
|
case "binary": {
|
|
const right = popValue()
|
|
const left = popValue()
|
|
stack.push(applyBinary(instr.operator, left, right, ctx))
|
|
break
|
|
}
|
|
case "to_bool": {
|
|
const value = popValue()
|
|
stack.push(makeBoolean(valueToBoolean(value)))
|
|
break
|
|
}
|
|
case "call_global": {
|
|
const args = popArgs(instr.argc)
|
|
stack.push(evalGlobalCallValues(instr.name, args, ctx, instr.span))
|
|
break
|
|
}
|
|
case "call_method": {
|
|
const args = popArgs(instr.argc)
|
|
const receiver = popValue()
|
|
stack.push(evalMethodCallValues(receiver, instr.name, args, ctx, instr.span))
|
|
break
|
|
}
|
|
case "call_dynamic": {
|
|
const calleeValue = popValue()
|
|
if (calleeValue.kind === "html") {
|
|
stack.push(makeHtml(calleeValue.value))
|
|
} else {
|
|
stack.push(makeNull())
|
|
}
|
|
break
|
|
}
|
|
case "filter": {
|
|
const receiver = popValue()
|
|
stack.push(applyListFilter(receiver, instr.program, ctx))
|
|
break
|
|
}
|
|
case "map": {
|
|
const receiver = popValue()
|
|
stack.push(applyListMap(receiver, instr.program, ctx))
|
|
break
|
|
}
|
|
case "reduce": {
|
|
const receiver = popValue()
|
|
stack.push(applyListReduce(receiver, instr.program, instr.initial, ctx))
|
|
break
|
|
}
|
|
case "jump":
|
|
ip = instr.target
|
|
continue
|
|
case "jump_if_false": {
|
|
const value = popValue()
|
|
if (!valueToBoolean(value)) {
|
|
ip = instr.target
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
case "jump_if_true": {
|
|
const value = popValue()
|
|
if (valueToBoolean(value)) {
|
|
ip = instr.target
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
}
|
|
ip += 1
|
|
}
|
|
|
|
return popValue()
|
|
}
|
|
|
|
export const evaluateExpression = (program: ProgramIR, ctx: EvalContext): Value =>
|
|
evaluateProgram(program, ctx)
|
|
|
|
export const evaluateFilterExpression = (program: ProgramIR, ctx: EvalContext): boolean =>
|
|
valueToBoolean(evaluateExpression(program, ctx))
|
|
|
|
export const evaluateSummaryExpression = (
|
|
program: ProgramIR,
|
|
values: unknown[],
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
const valueList = values.map(toValue)
|
|
const summaryCtx: EvalContext = { ...ctx, values: valueList }
|
|
return evaluateExpression(program, summaryCtx)
|
|
}
|
|
|
|
const resolveIdentifier = (name: string, ctx: EvalContext): Value => {
|
|
if (name === "this") return ctx.thisFile ? makeFile(ctx.thisFile) : makeNull()
|
|
if (ctx.locals && name in ctx.locals) {
|
|
const local = ctx.locals[name]
|
|
if (isValue(local)) return local
|
|
}
|
|
if (name === "file") return makeFile(ctx.file)
|
|
if (name === "note") {
|
|
const fm = ctx.file.frontmatter
|
|
return toValue(fm)
|
|
}
|
|
if (name === "values" && ctx.values) {
|
|
return makeList(ctx.values)
|
|
}
|
|
if (name === "rows" && ctx.rows) {
|
|
return makeList(ctx.rows.map((row) => makeFile(row)))
|
|
}
|
|
if (name === "formula") {
|
|
return makeObject({})
|
|
}
|
|
const raw: unknown = ctx.file.frontmatter ? ctx.file.frontmatter[name] : undefined
|
|
return toValue(raw)
|
|
}
|
|
|
|
const applyUnary = (operator: "!" | "-", value: Value): Value => {
|
|
if (operator === "!") {
|
|
return makeBoolean(!valueToBoolean(value))
|
|
}
|
|
const num = valueToNumber(value)
|
|
return Number.isFinite(num) ? makeNumber(-num) : makeNull()
|
|
}
|
|
|
|
const applyBinary = (
|
|
operator: BinaryExpr["operator"],
|
|
left: Value,
|
|
right: Value,
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
if (operator === "==") return makeBoolean(valueEquals(left, right, ctx))
|
|
if (operator === "!=") return makeBoolean(!valueEquals(left, right, ctx))
|
|
|
|
if (operator === "+" || operator === "-") {
|
|
return evalAdditive(operator, left, right)
|
|
}
|
|
if (operator === "*" || operator === "/" || operator === "%") {
|
|
if (isDurationValue(left)) {
|
|
const rightNum = valueToNumber(right)
|
|
if (!Number.isFinite(rightNum)) return makeNull()
|
|
if (operator === "*") return makeDuration(left.value * rightNum, left.months * rightNum)
|
|
if (operator === "/") {
|
|
if (rightNum === 0) return makeNull()
|
|
return makeDuration(left.value / rightNum, left.months / rightNum)
|
|
}
|
|
return makeNull()
|
|
}
|
|
if (isDurationValue(right)) return makeNull()
|
|
const leftNum = valueToNumber(left)
|
|
const rightNum = valueToNumber(right)
|
|
if (!Number.isFinite(leftNum) || !Number.isFinite(rightNum)) return makeNull()
|
|
if (operator === "*") return makeNumber(leftNum * rightNum)
|
|
if (operator === "/") return makeNumber(rightNum === 0 ? Number.NaN : leftNum / rightNum)
|
|
return makeNumber(rightNum === 0 ? Number.NaN : leftNum % rightNum)
|
|
}
|
|
|
|
const compare = compareValues(left, right)
|
|
if (compare === null) return makeNull()
|
|
if (operator === ">") return makeBoolean(compare > 0)
|
|
if (operator === ">=") return makeBoolean(compare >= 0)
|
|
if (operator === "<") return makeBoolean(compare < 0)
|
|
if (operator === "<=") return makeBoolean(compare <= 0)
|
|
return makeNull()
|
|
}
|
|
|
|
const evalAdditive = (operator: "+" | "-", left: Value, right: Value): Value => {
|
|
if (isDateValue(left) && isDateValue(right) && operator === "-") {
|
|
return makeDuration(left.value.getTime() - right.value.getTime())
|
|
}
|
|
if (isDateValue(left)) {
|
|
const duration = parseDurationValue(right)
|
|
if (duration === null) return makeNull()
|
|
return makeDate(addDurationToDate(left.value, duration, operator === "+" ? 1 : -1))
|
|
}
|
|
if (isDateValue(right) && operator === "+") {
|
|
const duration = parseDurationValue(left)
|
|
if (duration === null) return makeNull()
|
|
return makeDate(addDurationToDate(right.value, duration, 1))
|
|
}
|
|
if (operator === "+" && (isStringValue(left) || isStringValue(right))) {
|
|
return makeString(`${valueToString(left)}${valueToString(right)}`)
|
|
}
|
|
if (isDurationValue(left) && isDurationValue(right)) {
|
|
return makeDuration(
|
|
operator === "+" ? left.value + right.value : left.value - right.value,
|
|
operator === "+" ? left.months + right.months : left.months - right.months,
|
|
)
|
|
}
|
|
const leftNum = valueToNumber(left)
|
|
const rightNum = valueToNumber(right)
|
|
if (!Number.isFinite(leftNum) || !Number.isFinite(rightNum)) return makeNull()
|
|
return makeNumber(operator === "+" ? leftNum + rightNum : leftNum - rightNum)
|
|
}
|
|
|
|
const compareValues = (left: Value, right: Value): number | null => {
|
|
const leftDate = coerceToDate(left)
|
|
const rightDate = coerceToDate(right)
|
|
if (leftDate && rightDate) return leftDate.getTime() - rightDate.getTime()
|
|
|
|
const leftNum = coerceToNumber(left)
|
|
const rightNum = coerceToNumber(right)
|
|
if (leftNum !== null && rightNum !== null) return leftNum - rightNum
|
|
|
|
if (isScalarValue(left) && isScalarValue(right)) {
|
|
const leftStr = valueToString(left)
|
|
const rightStr = valueToString(right)
|
|
if (leftStr === rightStr) return 0
|
|
return leftStr > rightStr ? 1 : -1
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const accessIndex = (objectValue: Value, indexValue: Value): Value => {
|
|
if (isListValue(objectValue)) {
|
|
const index = Math.trunc(valueToNumber(indexValue))
|
|
if (!Number.isFinite(index)) return makeNull()
|
|
const item = objectValue.value[index]
|
|
return item ?? makeNull()
|
|
}
|
|
if (isObjectValue(objectValue) && isStringValue(indexValue)) {
|
|
const item = objectValue.value[indexValue.value]
|
|
return item ?? makeNull()
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const evalGlobalCallValues = (name: string, args: Value[], ctx: EvalContext, span: Span): Value => {
|
|
if (name === "if") {
|
|
if (args.length < 2) {
|
|
pushRuntimeDiagnostic(ctx, "if() expects at least 2 arguments", span)
|
|
}
|
|
const condition = args[0] ?? makeNull()
|
|
if (valueToBoolean(condition)) {
|
|
return args[1] ?? makeNull()
|
|
}
|
|
return args[2] ?? makeNull()
|
|
}
|
|
if (name === "now") return makeDate(new Date())
|
|
if (name === "today") {
|
|
const d = new Date()
|
|
d.setUTCHours(0, 0, 0, 0)
|
|
return makeDate(d)
|
|
}
|
|
if (name === "date") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "date() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const str = valueToString(arg)
|
|
const parsed = new Date(str)
|
|
if (Number.isNaN(parsed.getTime())) return makeNull()
|
|
return makeDate(parsed)
|
|
}
|
|
if (name === "duration") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "duration() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const parsed = parseDurationParts(valueToString(arg))
|
|
return makeDuration(parsed.ms, parsed.months)
|
|
}
|
|
if (name === "min" || name === "max") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, `${name}() expects at least 1 argument`, span)
|
|
}
|
|
const values = args.map((arg) => valueToNumber(arg))
|
|
const nums = values.filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
return makeNumber(name === "min" ? Math.min(...nums) : Math.max(...nums))
|
|
}
|
|
if (name === "number") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "number() expects 1 argument", span)
|
|
}
|
|
const value = args[0] ?? makeNull()
|
|
const num = valueToNumber(value)
|
|
return Number.isFinite(num) ? makeNumber(num) : makeNull()
|
|
}
|
|
if (name === "link") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "link() expects at least 1 argument", span)
|
|
}
|
|
const target = args[0] ?? makeNull()
|
|
const display = args[1] ?? makeNull()
|
|
const targetStr = valueToString(target)
|
|
const displayStr = valueToString(display)
|
|
return makeLink(targetStr, displayStr.length > 0 ? displayStr : undefined)
|
|
}
|
|
if (name === "list") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "list() expects 1 argument", span)
|
|
}
|
|
const value = args[0] ?? makeNull()
|
|
if (isListValue(value)) return value
|
|
return makeList([value])
|
|
}
|
|
if (name === "file") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "file() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const target = valueToString(arg)
|
|
const file = findFileByTarget(target, ctx)
|
|
return file ? makeFile(file) : makeNull()
|
|
}
|
|
if (name === "image") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "image() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const target = valueToString(arg)
|
|
return makeImage(target)
|
|
}
|
|
if (name === "icon") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "icon() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const target = valueToString(arg)
|
|
return makeIcon(target)
|
|
}
|
|
if (name === "html") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "html() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const target = valueToString(arg)
|
|
return makeHtml(target)
|
|
}
|
|
if (name === "escapeHTML") {
|
|
if (args.length < 1) {
|
|
pushRuntimeDiagnostic(ctx, "escapeHTML() expects 1 argument", span)
|
|
}
|
|
const arg = args[0] ?? makeNull()
|
|
const target = valueToString(arg)
|
|
const escaped = target
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
return makeString(escaped)
|
|
}
|
|
pushRuntimeDiagnostic(ctx, `unknown function: ${name}`, span)
|
|
return makeNull()
|
|
}
|
|
|
|
const evalMethodCallValues = (
|
|
receiver: Value,
|
|
method: string,
|
|
args: Value[],
|
|
ctx: EvalContext,
|
|
span: Span,
|
|
): Value => {
|
|
if (method === "isTruthy") {
|
|
return makeBoolean(valueToBoolean(receiver))
|
|
}
|
|
if (method === "isType") {
|
|
const arg = args[0] ?? makeNull()
|
|
const typeName = valueToString(arg).toLowerCase()
|
|
return makeBoolean(isValueType(receiver, typeName))
|
|
}
|
|
if (method === "toString") {
|
|
return makeString(valueToString(receiver))
|
|
}
|
|
if (receiver.kind === "null") {
|
|
if (method === "isEmpty") return makeBoolean(true)
|
|
if (method === "length") return makeNumber(0)
|
|
if (
|
|
method === "contains" ||
|
|
method === "containsAny" ||
|
|
method === "containsAll" ||
|
|
method === "startsWith" ||
|
|
method === "endsWith" ||
|
|
method === "matches"
|
|
) {
|
|
return makeBoolean(false)
|
|
}
|
|
if (method === "asFile" || method === "asLink") {
|
|
return makeNull()
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
if (isStringValue(receiver)) {
|
|
if (method === "asFile") {
|
|
const file = findFileByTarget(receiver.value, ctx)
|
|
return file ? makeFile(file) : makeNull()
|
|
}
|
|
if (!stringMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown string method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalStringMethod(receiver, method, args)
|
|
}
|
|
if (isNumberValue(receiver)) {
|
|
if (!numberMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown number method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalNumberMethod(receiver, method, args)
|
|
}
|
|
if (isListValue(receiver)) {
|
|
if (!listMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown list method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalListMethod(receiver, method, args, ctx)
|
|
}
|
|
if (isDateValue(receiver)) {
|
|
if (!dateMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown date method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalDateMethod(receiver, method, args)
|
|
}
|
|
if (isFileValue(receiver)) {
|
|
if (!fileMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown file method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalFileMethod(receiver, method, args, ctx)
|
|
}
|
|
if (isLinkValue(receiver)) {
|
|
if (!linkMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown link method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalLinkMethod(receiver, method, args, ctx)
|
|
}
|
|
if (isObjectValue(receiver)) {
|
|
if (!objectMethods.has(method)) {
|
|
pushRuntimeDiagnostic(ctx, `unknown object method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
return evalObjectMethod(receiver, method)
|
|
}
|
|
if (isRegexValue(receiver)) {
|
|
if (method === "matches") {
|
|
const value = args[0] ?? makeNull()
|
|
return makeBoolean(receiver.value.test(valueToString(value)))
|
|
}
|
|
pushRuntimeDiagnostic(ctx, `unknown regex method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
pushRuntimeDiagnostic(ctx, `unknown ${receiver.kind} method: ${method}`, span)
|
|
return makeNull()
|
|
}
|
|
|
|
const evalStringMethod = (receiver: StringValue, method: string, args: Value[]): Value => {
|
|
const value = receiver.value
|
|
if (method === "contains") {
|
|
const arg = args[0] ?? makeNull()
|
|
return makeBoolean(value.includes(valueToString(arg)))
|
|
}
|
|
if (method === "containsAny") {
|
|
const values = args.map((arg) => valueToString(arg))
|
|
return makeBoolean(values.some((entry) => value.includes(entry)))
|
|
}
|
|
if (method === "containsAll") {
|
|
const values = args.map((arg) => valueToString(arg))
|
|
return makeBoolean(values.every((entry) => value.includes(entry)))
|
|
}
|
|
if (method === "startsWith") {
|
|
const arg = args[0] ?? makeNull()
|
|
return makeBoolean(value.startsWith(valueToString(arg)))
|
|
}
|
|
if (method === "endsWith") {
|
|
const arg = args[0] ?? makeNull()
|
|
return makeBoolean(value.endsWith(valueToString(arg)))
|
|
}
|
|
if (method === "isEmpty") {
|
|
return makeBoolean(value.length === 0)
|
|
}
|
|
if (method === "lower") {
|
|
return makeString(value.toLowerCase())
|
|
}
|
|
if (method === "title") {
|
|
const parts = value.split(/\s+/).map((part) => {
|
|
const lower = part.toLowerCase()
|
|
return lower.length > 0 ? `${lower[0].toUpperCase()}${lower.slice(1)}` : lower
|
|
})
|
|
return makeString(parts.join(" "))
|
|
}
|
|
if (method === "trim") {
|
|
return makeString(value.trim())
|
|
}
|
|
if (method === "replace") {
|
|
const patternVal = args[0] ?? makeNull()
|
|
const replacementVal = args[1] ?? makeNull()
|
|
const replacement = valueToString(replacementVal)
|
|
if (isRegexValue(patternVal)) {
|
|
return makeString(value.replace(patternVal.value, replacement))
|
|
}
|
|
return makeString(value.replace(valueToString(patternVal), replacement))
|
|
}
|
|
if (method === "repeat") {
|
|
const count = args[0] ? valueToNumber(args[0]) : 0
|
|
return makeString(value.repeat(Number.isFinite(count) ? Math.max(0, count) : 0))
|
|
}
|
|
if (method === "reverse") {
|
|
return makeString(value.split("").reverse().join(""))
|
|
}
|
|
if (method === "slice") {
|
|
const start = args[0] ? valueToNumber(args[0]) : 0
|
|
const end = args[1] ? valueToNumber(args[1]) : undefined
|
|
const startIndex = Number.isFinite(start) ? Math.trunc(start) : 0
|
|
const endIndex = end !== undefined && Number.isFinite(end) ? Math.trunc(end) : undefined
|
|
return makeString(value.slice(startIndex, endIndex))
|
|
}
|
|
if (method === "split") {
|
|
const separatorValue = args[0] ?? makeString("")
|
|
const separator = isRegexValue(separatorValue)
|
|
? separatorValue.value
|
|
: valueToString(separatorValue)
|
|
const limitValue = args[1] ? valueToNumber(args[1]) : undefined
|
|
const limit =
|
|
limitValue !== undefined && Number.isFinite(limitValue) ? Math.trunc(limitValue) : undefined
|
|
const parts = limit !== undefined ? value.split(separator, limit) : value.split(separator)
|
|
return makeList(parts.map((entry) => makeString(entry)))
|
|
}
|
|
if (method === "length") {
|
|
return makeNumber(value.length)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const evalNumberMethod = (receiver: NumberValue, method: string, args: Value[]): Value => {
|
|
const value = receiver.value
|
|
if (method === "abs") return makeNumber(Math.abs(value))
|
|
if (method === "ceil") return makeNumber(Math.ceil(value))
|
|
if (method === "floor") return makeNumber(Math.floor(value))
|
|
if (method === "round") {
|
|
const digits = args[0] ? valueToNumber(args[0]) : 0
|
|
if (!Number.isFinite(digits)) return makeNumber(Math.round(value))
|
|
const factor = 10 ** Math.trunc(digits)
|
|
return makeNumber(Math.round(value * factor) / factor)
|
|
}
|
|
if (method === "toFixed") {
|
|
const digits = args[0] ? valueToNumber(args[0]) : 0
|
|
const precision = Number.isFinite(digits) ? Math.trunc(digits) : 0
|
|
return makeString(value.toFixed(Math.max(0, precision)))
|
|
}
|
|
if (method === "isEmpty") {
|
|
return makeBoolean(!Number.isFinite(value))
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const evalListMethod = (
|
|
receiver: ListValue,
|
|
method: string,
|
|
args: Value[],
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
const list = receiver.value
|
|
if (method === "contains") {
|
|
const arg = args[0] ?? makeNull()
|
|
return makeBoolean(list.some((entry) => valueEquals(entry, arg, ctx)))
|
|
}
|
|
if (method === "containsAny") {
|
|
const values = args
|
|
return makeBoolean(values.some((entry) => list.some((item) => valueEquals(item, entry, ctx))))
|
|
}
|
|
if (method === "containsAll") {
|
|
const values = args
|
|
return makeBoolean(values.every((entry) => list.some((item) => valueEquals(item, entry, ctx))))
|
|
}
|
|
if (method === "flat") {
|
|
const flattened: Value[] = []
|
|
for (const item of list) {
|
|
if (isListValue(item)) {
|
|
flattened.push(...item.value)
|
|
} else {
|
|
flattened.push(item)
|
|
}
|
|
}
|
|
return makeList(flattened)
|
|
}
|
|
if (method === "join") {
|
|
const separator = args[0] ? valueToString(args[0]) : ","
|
|
return makeString(list.map(valueToString).join(separator))
|
|
}
|
|
if (method === "reverse") {
|
|
return makeList([...list].reverse())
|
|
}
|
|
if (method === "slice") {
|
|
const start = args[0] ? valueToNumber(args[0]) : 0
|
|
const end = args[1] ? valueToNumber(args[1]) : undefined
|
|
const startIndex = Number.isFinite(start) ? Math.trunc(start) : 0
|
|
const endIndex = end !== undefined && Number.isFinite(end) ? Math.trunc(end) : undefined
|
|
return makeList(list.slice(startIndex, endIndex))
|
|
}
|
|
if (method === "sort") {
|
|
const sorted = [...list].sort((a, b) => {
|
|
const aNum = valueToNumber(a)
|
|
const bNum = valueToNumber(b)
|
|
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
|
|
const aStr = valueToString(a)
|
|
const bStr = valueToString(b)
|
|
if (aStr === bStr) return 0
|
|
return aStr > bStr ? 1 : -1
|
|
})
|
|
return makeList(sorted)
|
|
}
|
|
if (method === "unique") {
|
|
const unique: Value[] = []
|
|
for (const item of list) {
|
|
if (!unique.some((entry) => valueEquals(entry, item, ctx))) {
|
|
unique.push(item)
|
|
}
|
|
}
|
|
return makeList(unique)
|
|
}
|
|
if (method === "sum") {
|
|
const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
return makeNumber(nums.reduce((acc, value) => acc + value, 0))
|
|
}
|
|
if (method === "mean" || method === "average") {
|
|
const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
const sum = nums.reduce((acc, value) => acc + value, 0)
|
|
return makeNumber(sum / nums.length)
|
|
}
|
|
if (method === "median") {
|
|
const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
const sorted = [...nums].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
if (sorted.length % 2 === 0) {
|
|
return makeNumber((sorted[mid - 1] + sorted[mid]) / 2)
|
|
}
|
|
return makeNumber(sorted[mid])
|
|
}
|
|
if (method === "stddev") {
|
|
const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
const mean = nums.reduce((acc, value) => acc + value, 0) / nums.length
|
|
const variance = nums.reduce((acc, value) => acc + (value - mean) ** 2, 0) / nums.length
|
|
return makeNumber(Math.sqrt(variance))
|
|
}
|
|
if (method === "min" || method === "max") {
|
|
const nums = list.map(valueToNumber).filter((value) => Number.isFinite(value))
|
|
if (nums.length === 0) return makeNull()
|
|
const value = method === "min" ? Math.min(...nums) : Math.max(...nums)
|
|
return makeNumber(value)
|
|
}
|
|
if (method === "isEmpty") {
|
|
return makeBoolean(list.length === 0)
|
|
}
|
|
if (method === "length") {
|
|
return makeNumber(list.length)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const applyListFilter = (receiver: Value, program: ProgramIR | null, ctx: EvalContext): Value => {
|
|
if (!isListValue(receiver)) return makeNull()
|
|
if (!program) return makeList(receiver.value)
|
|
const list = receiver.value
|
|
const filtered = list.filter((value, index) => {
|
|
const locals: Record<string, Value> = { value, index: makeNumber(index) }
|
|
const baseLocals = ctx.locals ? ctx.locals : {}
|
|
const fileCtx = isFileValue(value)
|
|
? {
|
|
...ctx,
|
|
file: value.value,
|
|
propertyCache: undefined,
|
|
formulaCache: undefined,
|
|
formulaStack: new Set<string>(),
|
|
}
|
|
: ctx
|
|
const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } }
|
|
return valueToBoolean(evaluateProgram(program, nextCtx))
|
|
})
|
|
return makeList(filtered)
|
|
}
|
|
|
|
const applyListMap = (receiver: Value, program: ProgramIR | null, ctx: EvalContext): Value => {
|
|
if (!isListValue(receiver)) return makeNull()
|
|
if (!program) return makeList(receiver.value)
|
|
const list = receiver.value
|
|
const mapped = list.map((value, index) => {
|
|
const locals: Record<string, Value> = { value, index: makeNumber(index) }
|
|
const baseLocals = ctx.locals ? ctx.locals : {}
|
|
const fileCtx = isFileValue(value)
|
|
? {
|
|
...ctx,
|
|
file: value.value,
|
|
propertyCache: undefined,
|
|
formulaCache: undefined,
|
|
formulaStack: new Set<string>(),
|
|
}
|
|
: ctx
|
|
const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } }
|
|
return evaluateProgram(program, nextCtx)
|
|
})
|
|
return makeList(mapped)
|
|
}
|
|
|
|
const applyListReduce = (
|
|
receiver: Value,
|
|
program: ProgramIR | null,
|
|
initial: ProgramIR | null,
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
if (!isListValue(receiver)) return makeNull()
|
|
const initialValue = initial ? evaluateProgram(initial, ctx) : makeNull()
|
|
if (!program) return initialValue
|
|
let acc = initialValue
|
|
const list = receiver.value
|
|
for (let index = 0; index < list.length; index += 1) {
|
|
const value = list[index]
|
|
const locals: Record<string, Value> = { value, index: makeNumber(index), acc }
|
|
const baseLocals = ctx.locals ? ctx.locals : {}
|
|
const fileCtx = isFileValue(value)
|
|
? {
|
|
...ctx,
|
|
file: value.value,
|
|
propertyCache: undefined,
|
|
formulaCache: undefined,
|
|
formulaStack: new Set<string>(),
|
|
}
|
|
: ctx
|
|
const nextCtx: EvalContext = { ...fileCtx, locals: { ...baseLocals, ...locals } }
|
|
acc = evaluateProgram(program, nextCtx)
|
|
}
|
|
return acc
|
|
}
|
|
|
|
const evalDateMethod = (receiver: DateValue, method: string, args: Value[]): Value => {
|
|
const value = receiver.value
|
|
if (method === "date") {
|
|
const date = new Date(value.getTime())
|
|
date.setUTCHours(0, 0, 0, 0)
|
|
return makeDate(date)
|
|
}
|
|
if (method === "format") {
|
|
const pattern = args[0] ? valueToString(args[0]) : "YYYY-MM-DD"
|
|
return makeString(formatDatePattern(value, pattern))
|
|
}
|
|
if (method === "time") {
|
|
return makeString(formatTime(value))
|
|
}
|
|
if (method === "relative") {
|
|
return makeString(formatRelative(value))
|
|
}
|
|
if (method === "isEmpty") {
|
|
return makeBoolean(false)
|
|
}
|
|
if (method === "year") return makeNumber(value.getUTCFullYear())
|
|
if (method === "month") return makeNumber(value.getUTCMonth() + 1)
|
|
if (method === "day") return makeNumber(value.getUTCDate())
|
|
if (method === "hour") return makeNumber(value.getUTCHours())
|
|
if (method === "minute") return makeNumber(value.getUTCMinutes())
|
|
if (method === "second") return makeNumber(value.getUTCSeconds())
|
|
if (method === "millisecond") return makeNumber(value.getUTCMilliseconds())
|
|
return makeNull()
|
|
}
|
|
|
|
const evalObjectMethod = (receiver: ObjectValue, method: string): Value => {
|
|
const entries = Object.entries(receiver.value)
|
|
if (method === "isEmpty") return makeBoolean(entries.length === 0)
|
|
if (method === "keys") return makeList(entries.map(([key]) => makeString(key)))
|
|
if (method === "values") return makeList(entries.map(([, value]) => value))
|
|
return makeNull()
|
|
}
|
|
|
|
const evalFileMethod = (
|
|
receiver: FileValue,
|
|
method: string,
|
|
args: Value[],
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
const file = receiver.value
|
|
if (method === "asLink") {
|
|
const display = args[0] ? valueToString(args[0]) : undefined
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
return makeLink(slug, display)
|
|
}
|
|
if (method === "hasTag") {
|
|
const tags = args.map((arg) => valueToString(arg))
|
|
const rawTags = file.frontmatter?.tags
|
|
const fileTags = Array.isArray(rawTags) ? rawTags : typeof rawTags === "string" ? [rawTags] : []
|
|
return makeBoolean(tags.some((tag) => fileTags.includes(tag)))
|
|
}
|
|
if (method === "inFolder") {
|
|
const folder = args[0] ? valueToString(args[0]) : ""
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
const normalized = folder.endsWith("/") ? folder : `${folder}/`
|
|
return makeBoolean(slug.startsWith(normalized))
|
|
}
|
|
if (method === "hasProperty") {
|
|
const prop = args[0] ? valueToString(args[0]) : ""
|
|
const fm = file.frontmatter
|
|
return makeBoolean(Boolean(fm && prop in fm))
|
|
}
|
|
if (method === "hasLink") {
|
|
const arg = args[0] ?? makeNull()
|
|
const targetSlug = resolveLinkSlugFromValue(arg, ctx)
|
|
if (!targetSlug) return makeBoolean(false)
|
|
const links = Array.isArray(file.links) ? file.links.map((link) => String(link)) : []
|
|
return makeBoolean(links.includes(targetSlug))
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const evalLinkMethod = (
|
|
receiver: LinkValue,
|
|
method: string,
|
|
args: Value[],
|
|
ctx: EvalContext,
|
|
): Value => {
|
|
if (method === "asFile") {
|
|
const file = findFileByTarget(receiver.value, ctx)
|
|
return file ? makeFile(file) : makeNull()
|
|
}
|
|
if (method === "linksTo") {
|
|
const arg = args[0] ?? makeNull()
|
|
const targetSlug = resolveLinkSlugFromValue(arg, ctx)
|
|
const receiverSlug = resolveLinkSlugFromText(receiver.value, ctx)
|
|
if (!targetSlug || !receiverSlug) return makeBoolean(false)
|
|
return makeBoolean(receiverSlug === targetSlug)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const resolveFormulaProperty = (name: string, ctx: EvalContext): Value => {
|
|
if (!ctx.formulas || !ctx.formulas[name]) return makeNull()
|
|
if (!ctx.formulaCache) ctx.formulaCache = new Map()
|
|
if (!ctx.formulaStack) ctx.formulaStack = new Set()
|
|
const cached = ctx.formulaCache.get(name)
|
|
if (cached) return cached
|
|
if (ctx.formulaStack.has(name)) return makeNull()
|
|
ctx.formulaStack.add(name)
|
|
const expr = ctx.formulas[name]
|
|
const nextCtx: EvalContext = {
|
|
...ctx,
|
|
diagnosticContext: `formula.${name}`,
|
|
diagnosticSource: ctx.formulaSources?.[name] ?? ctx.diagnosticSource,
|
|
}
|
|
const value = evaluateExpression(expr, nextCtx)
|
|
ctx.formulaCache.set(name, value)
|
|
ctx.formulaStack.delete(name)
|
|
return value
|
|
}
|
|
|
|
const resolveFileProperty = (file: QuartzPluginData, property: string, ctx: EvalContext): Value => {
|
|
if (property === "file") return makeFile(file)
|
|
if (property === "name" || property === "basename") {
|
|
const filePath = typeof file.filePath === "string" ? file.filePath : ""
|
|
const source = filePath.length > 0 ? filePath : file.slug ? String(file.slug) : ""
|
|
const segment = source.split("/").pop() || ""
|
|
if (property === "name") return makeString(segment)
|
|
const basename = segment.replace(/\.[^/.]+$/, "")
|
|
return makeString(basename)
|
|
}
|
|
if (property === "title") {
|
|
const title = typeof file.frontmatter?.title === "string" ? file.frontmatter.title : ""
|
|
if (title.length > 0) return makeString(title)
|
|
return resolveFileProperty(file, "basename", ctx)
|
|
}
|
|
if (property === "path") {
|
|
const path = file.filePath || file.slug || ""
|
|
return makeString(String(path))
|
|
}
|
|
if (property === "folder") {
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
const parts = slug.split("/")
|
|
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : ""
|
|
return makeString(folder)
|
|
}
|
|
if (property === "ext") {
|
|
const filePath = typeof file.filePath === "string" ? file.filePath : ""
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
const source = filePath.length > 0 ? filePath : slug
|
|
const match = source.match(/\.([^.]+)$/)
|
|
return makeString(match ? match[1] : "md")
|
|
}
|
|
if (property === "size") {
|
|
if (isRecord(file) && typeof file.size === "number") {
|
|
return makeNumber(file.size)
|
|
}
|
|
return makeNull()
|
|
}
|
|
if (property === "ctime") {
|
|
const created = file.dates?.created
|
|
if (!created) return makeNull()
|
|
return makeDate(new Date(created))
|
|
}
|
|
if (property === "mtime") {
|
|
const modified = file.dates?.modified
|
|
if (!modified) return makeNull()
|
|
return makeDate(new Date(modified))
|
|
}
|
|
if (property === "tags") {
|
|
const rawTags = file.frontmatter?.tags
|
|
const tags = Array.isArray(rawTags) ? rawTags : typeof rawTags === "string" ? [rawTags] : []
|
|
return makeList(tags.map((tag) => makeString(String(tag))))
|
|
}
|
|
if (property === "aliases") {
|
|
const aliases = file.frontmatter?.aliases
|
|
if (!aliases) return makeList([])
|
|
const list = Array.isArray(aliases) ? aliases : [aliases]
|
|
return makeList(list.map((alias) => makeString(String(alias))))
|
|
}
|
|
if (property === "links" || property === "outlinks") {
|
|
const links = Array.isArray(file.links) ? file.links : []
|
|
return makeList(links.map((link) => makeLink(String(link))))
|
|
}
|
|
if (property === "backlinks" || property === "inlinks") {
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
const key = simplifySlug(slug as FullSlug)
|
|
const backlinks =
|
|
ctx.backlinksIndex?.get(key) ??
|
|
ctx.allFiles
|
|
.filter((entry) => {
|
|
const links = Array.isArray(entry.links) ? entry.links.map((link) => String(link)) : []
|
|
return key.length > 0 && links.includes(key)
|
|
})
|
|
.map((entry) => (entry.slug ? simplifySlug(entry.slug as FullSlug) : ""))
|
|
.filter((entry) => entry.length > 0)
|
|
return makeList(backlinks.map((link) => makeLink(String(link))))
|
|
}
|
|
if (property === "embeds") {
|
|
const embeds = isRecord(file) && Array.isArray(file.embeds) ? file.embeds : []
|
|
return makeList(embeds.map((entry) => makeString(String(entry))))
|
|
}
|
|
if (property === "properties") {
|
|
return toValue(file.frontmatter)
|
|
}
|
|
if (property === "link") {
|
|
const slug = file.slug ? String(file.slug) : ""
|
|
return makeLink(slug)
|
|
}
|
|
const raw: unknown = file.frontmatter ? file.frontmatter[property] : undefined
|
|
return toValue(raw)
|
|
}
|
|
|
|
const accessProperty = (value: Value, property: string, ctx: EvalContext): Value => {
|
|
if (isStringValue(value) && property === "length") return makeNumber(value.value.length)
|
|
if (isListValue(value) && property === "length") return makeNumber(value.value.length)
|
|
if (isDateValue(value)) {
|
|
if (property === "year") return makeNumber(value.value.getUTCFullYear())
|
|
if (property === "month") return makeNumber(value.value.getUTCMonth() + 1)
|
|
if (property === "day") return makeNumber(value.value.getUTCDate())
|
|
if (property === "hour") return makeNumber(value.value.getUTCHours())
|
|
if (property === "minute") return makeNumber(value.value.getUTCMinutes())
|
|
if (property === "second") return makeNumber(value.value.getUTCSeconds())
|
|
if (property === "millisecond") return makeNumber(value.value.getUTCMilliseconds())
|
|
}
|
|
if (isObjectValue(value)) {
|
|
return value.value[property] ?? makeNull()
|
|
}
|
|
if (isFileValue(value)) {
|
|
return resolveFileProperty(value.value, property, ctx)
|
|
}
|
|
if (isLinkValue(value)) {
|
|
if (property === "value") return makeString(value.value)
|
|
}
|
|
return makeNull()
|
|
}
|
|
|
|
const isValueType = (value: Value, typeName: string): boolean => {
|
|
if (typeName === "null" || typeName === "undefined") return value.kind === "null"
|
|
if (typeName === "string") return value.kind === "string"
|
|
if (typeName === "number") return value.kind === "number"
|
|
if (typeName === "boolean") return value.kind === "boolean"
|
|
if (typeName === "array" || typeName === "list") return value.kind === "list"
|
|
if (typeName === "object") return value.kind === "object"
|
|
if (typeName === "date") return value.kind === "date"
|
|
if (typeName === "duration") return value.kind === "duration"
|
|
if (typeName === "file") return value.kind === "file"
|
|
if (typeName === "link") return value.kind === "link"
|
|
return false
|
|
}
|
|
|
|
const resolveFileSlug = (file: QuartzPluginData): string | undefined => {
|
|
if (!file.slug) return undefined
|
|
return simplifySlug(file.slug as FullSlug)
|
|
}
|
|
|
|
const normalizeLinkText = (value: string): string => value.trim()
|
|
|
|
const resolveLinkSlugFromText = (raw: string, ctx: EvalContext): string | undefined => {
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return undefined
|
|
if (/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return undefined
|
|
const currentSlug = ctx.file.slug
|
|
? String(ctx.file.slug)
|
|
: ctx.thisFile?.slug
|
|
? String(ctx.thisFile.slug)
|
|
: undefined
|
|
const parsed = parseWikilink(trimmed)
|
|
if (parsed) {
|
|
if (/^[a-z][a-z0-9+.-]*:/.test(parsed.target)) return undefined
|
|
if (currentSlug) {
|
|
const resolved = resolveWikilinkTarget(parsed, currentSlug as FullSlug)
|
|
return resolved ? simplifySlug(resolved.slug) : undefined
|
|
}
|
|
const parsedTarget = parsed.target.trim()
|
|
if (!parsedTarget) return undefined
|
|
const slug = slugifyFilePath(parsedTarget as FilePath)
|
|
return simplifySlug(slug)
|
|
}
|
|
const [target, anchor] = splitAnchor(trimmed)
|
|
if (currentSlug) {
|
|
const resolved = resolveWikilinkTarget(
|
|
{
|
|
raw: trimmed,
|
|
target,
|
|
anchor: anchor.length > 0 ? anchor : undefined,
|
|
alias: undefined,
|
|
embed: trimmed.startsWith("!"),
|
|
},
|
|
currentSlug as FullSlug,
|
|
)
|
|
if (resolved) return simplifySlug(resolved.slug)
|
|
}
|
|
if (!target) return undefined
|
|
const normalized = target.replace(/\\/g, "/").replace(/^\/+/, "")
|
|
if (!normalized) return undefined
|
|
const slug = slugifyFilePath(normalized as FilePath)
|
|
return simplifySlug(slug)
|
|
}
|
|
|
|
const resolveLinkSlugFromValue = (value: Value, ctx: EvalContext): string | undefined => {
|
|
if (isFileValue(value)) return resolveFileSlug(value.value)
|
|
if (isLinkValue(value)) return resolveLinkSlugFromText(value.value, ctx)
|
|
if (isStringValue(value)) return resolveLinkSlugFromText(value.value, ctx)
|
|
return undefined
|
|
}
|
|
|
|
const resolveLinkComparisonKey = (
|
|
value: Value,
|
|
ctx: EvalContext,
|
|
): { slug?: string; text: string } => {
|
|
if (isFileValue(value)) {
|
|
const slug = resolveFileSlug(value.value)
|
|
return { slug, text: slug ?? "" }
|
|
}
|
|
if (isLinkValue(value)) {
|
|
const slug = resolveLinkSlugFromText(value.value, ctx)
|
|
const text = value.display && value.display.length > 0 ? value.display : value.value
|
|
return { slug, text: normalizeLinkText(text) }
|
|
}
|
|
if (isStringValue(value)) {
|
|
const slug = resolveLinkSlugFromText(value.value, ctx)
|
|
return { slug, text: normalizeLinkText(value.value) }
|
|
}
|
|
return { text: normalizeLinkText(valueToString(value)) }
|
|
}
|
|
|
|
const findFileByTarget = (target: string, ctx: EvalContext): QuartzPluginData | undefined => {
|
|
const slug = resolveLinkSlugFromText(target, ctx)
|
|
if (!slug) return undefined
|
|
const indexed = ctx.fileIndex?.get(slug)
|
|
if (indexed) return indexed
|
|
return ctx.allFiles.find((entry) => resolveFileSlug(entry) === slug)
|
|
}
|