import { createFeature, FeatureContext, FeatureTransformContext } from './feature'; import { unbox, Box, deprecatedStFunctions, boxString } from '../custom-values'; import { generalDiagnostics } from './diagnostics'; import * as STSymbol from './st-symbol'; import type { StylableMeta } from '../stylable-meta'; import { createSymbolResolverWithCache, CSSResolve } from '../stylable-resolver'; import { EvalValueData, EvalValueResult, StylableEvaluator } from '../functions'; import { isChildOfAtRule } from '../helpers/rule'; import { walkSelector } from '../helpers/selector'; import { stringifyFunction, getStringValue, strategies } from '../helpers/value'; import { stripQuotation } from '../helpers/string'; import type { ImmutablePseudoClass, PseudoClass } from '@tokey/css-selector-parser'; import type * as postcss from 'postcss'; import { processDeclarationFunctions } from '../process-declaration-functions'; import { createDiagnosticReporter, Diagnostics } from '../diagnostics'; import type { ParsedValue } from '../types'; import type { Stylable } from '../stylable'; import type { RuntimeStVar } from '../stylable-transformer'; import postcssValueParser from 'postcss-value-parser'; export interface VarSymbol { _kind: 'var'; name: string; value: string; text: string; valueType: string | null; node: postcss.Node; } export type CustomValueInput = Box< string, CustomValueInput | Record | Array >; export interface ComputedStVar { value: RuntimeStVar; diagnostics: Diagnostics; input: CustomValueInput; source: { meta: StylableMeta; start: postcss.Position; end: postcss.Position; }; } export interface FlatComputedStVar { value: string; path: string[]; source: ComputedStVar['source']; } export const diagnostics = { FORBIDDEN_DEF_IN_COMPLEX_SELECTOR: generalDiagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR, NO_VARS_DEF_IN_ST_SCOPE: createDiagnosticReporter( '07002', 'error', () => `cannot define ":vars" inside of "@st-scope"` ), DEPRECATED_ST_FUNCTION_NAME: createDiagnosticReporter( '07003', 'info', (name: string, alternativeName: string) => `"${name}" is deprecated, use "${alternativeName}"` ), CYCLIC_VALUE: createDiagnosticReporter( '07004', 'error', (cyclicChain: string[]) => `Cyclic value definition detected: "${cyclicChain .map((s, i) => (i === cyclicChain.length - 1 ? '↻ ' : i === 0 ? '→ ' : '↪ ') + s) .join('\n')}"` ), MISSING_VAR_IN_VALUE: createDiagnosticReporter( '07005', 'error', () => `invalid value() with no var identifier` ), COULD_NOT_RESOLVE_VALUE: createDiagnosticReporter( '07006', 'error', (args?: string) => `cannot resolve value function${args ? ` using the arguments provided: "${args}"` : ''}` ), MULTI_ARGS_IN_VALUE: createDiagnosticReporter( '07007', 'error', (args: string) => `value function accepts only a single argument: "value(${args})"` ), CANNOT_USE_AS_VALUE: createDiagnosticReporter( '07008', 'error', (type: string, varName: string) => `${type} "${varName}" cannot be used as a variable` ), CANNOT_USE_JS_AS_VALUE: createDiagnosticReporter( '07009', 'error', (type: string, varName: string) => `JavaScript ${type} import "${varName}" cannot be used as a variable` ), UNKNOWN_VAR: createDiagnosticReporter( '07010', 'error', (name: string) => `unknown var "${name}"` ), }; // HOOKS export const hooks = createFeature<{ SELECTOR: PseudoClass; IMMUTABLE_SELECTOR: ImmutablePseudoClass; RESOLVED: Record; }>({ analyzeSelectorNode({ context, node, rule }) { if (node.type !== `pseudo_class` || node.value !== `vars`) { return; } // make sure `:vars` is the only selector if (rule.selector === `:vars`) { if (isChildOfAtRule(rule, `st-scope`)) { context.diagnostics.report(diagnostics.NO_VARS_DEF_IN_ST_SCOPE(), { node: rule }); } else { collectVarSymbols(context, rule); } // stop further walk into `:vars {}` return walkSelector.stopAll; } else { context.diagnostics.report(diagnostics.FORBIDDEN_DEF_IN_COMPLEX_SELECTOR(`:vars`), { node: rule, }); } return; }, prepareAST({ node, toRemove }) { if (node.type === 'rule' && node.selector === ':vars') { toRemove.push(node); } }, transformResolve({ context }) { // Resolve local vars const resolved: Record = {}; const symbols = STSymbol.getAllByType(context.meta, `var`); // Temporarily don't report issues here // ToDo: move reporting here (from value() transformation) const noDaigContext = { ...context, diagnostics: new Diagnostics(), }; for (const name of Object.keys(symbols)) { const symbol = symbols[name]; const evaluated = context.evaluator.evaluateValue(noDaigContext, { // ToDo: change to `value(${name})` in order to fix overrides in exports value: stripQuotation(symbol.text), meta: context.meta, node: symbol.node, }); resolved[name] = evaluated; } return resolved; }, transformValue({ context, node, data }) { evaluateValueCall(context, node, data); }, transformJSExports({ exports, resolved }) { for (const [name, { topLevelType, outputValue }] of Object.entries(resolved)) { exports.stVars[name] = topLevelType ? unbox(topLevelType) : outputValue; } }, }); // API export function get(meta: StylableMeta, name: string): VarSymbol | undefined { return STSymbol.get(meta, name, `var`); } // Stylable StVar Public APIs const UNKNOWN_LOCATION = Object.freeze({ offset: -1, line: -1, column: -1, } as const); export class StylablePublicApi { constructor(private stylable: Stylable) {} public getComputed(meta: StylableMeta) { const topLevelDiagnostics = new Diagnostics(); const getResolvedSymbols = createSymbolResolverWithCache( this.stylable.resolver, topLevelDiagnostics ); const evaluator = new StylableEvaluator({ getResolvedSymbols }); const { var: stVars } = getResolvedSymbols(meta); const computed: Record = {}; for (const [localName, resolvedVar] of Object.entries(stVars)) { const diagnostics = new Diagnostics(); const { outputValue, topLevelType, runtimeValue } = evaluator.evaluateValue( { resolver: this.stylable.resolver, evaluator, meta, diagnostics, }, { meta: resolvedVar.meta, value: stripQuotation(resolvedVar.symbol.text), node: resolvedVar.symbol.node, } ); const computedStVar: ComputedStVar = { value: runtimeValue ?? outputValue, input: topLevelType ?? unbox(outputValue, false), diagnostics, source: { meta: resolvedVar.meta, start: resolvedVar.symbol.node.source?.start || UNKNOWN_LOCATION, end: resolvedVar.symbol.node.source?.end || UNKNOWN_LOCATION, }, }; computed[localName] = computedStVar; } return computed; } public flatten(meta: StylableMeta) { const computed = this.getComputed(meta); const flatStVars: FlatComputedStVar[] = []; for (const [symbol, stVar] of Object.entries(computed)) { flatStVars.push(...this.flatSingle(stVar.input, [symbol], stVar.source)); } return flatStVars; } private flatSingle(input: CustomValueInput, path: string[], source: ComputedStVar['source']) { const currentVars: FlatComputedStVar[] = []; if (input.flatValue) { currentVars.push({ value: input.flatValue, path, source, }); } if (typeof input.value === `object` && input.value !== null) { for (const [key, innerInput] of Object.entries(input.value)) { currentVars.push( ...this.flatSingle( typeof innerInput === 'string' ? boxString(innerInput) : innerInput, [...path, key], source ) ); } } return currentVars; } } export function parseVarsFromExpr(expr: string) { const nameSet = new Set(); postcssValueParser(expr).walk((node) => { if (node.type === 'function' && node.value === 'value') { for (const argNode of node.nodes) { switch (argNode.type) { case 'word': nameSet.add(argNode.value); return; case 'div': if (argNode.value === ',') { return; } } } } }); return nameSet; } function collectVarSymbols(context: FeatureContext, rule: postcss.Rule) { rule.walkDecls((decl) => { warnOnDeprecatedCustomValues(context, decl); // check type annotation let type = null; const prev = decl.prev() as postcss.Comment; if (prev && prev.type === 'comment') { const typeMatch = prev.text.match(/^@type (.+)$/); if (typeMatch) { type = typeMatch[1]; } } // add symbol const name = decl.prop; STSymbol.addSymbol({ context, symbol: { _kind: 'var', name, value: '', text: decl.value, node: decl, valueType: type, }, node: decl, }); }); } function warnOnDeprecatedCustomValues(context: FeatureContext, decl: postcss.Declaration) { processDeclarationFunctions( decl, (node) => { if (node.type === 'nested-item' && deprecatedStFunctions[node.name]) { const { alternativeName } = deprecatedStFunctions[node.name]; context.diagnostics.report( diagnostics.DEPRECATED_ST_FUNCTION_NAME(node.name, alternativeName), { node: decl, word: node.name, } ); } }, false ); } function evaluateValueCall( context: FeatureTransformContext, parsedNode: ParsedValue, data: EvalValueData ): void { const { stVarOverride, value, node } = data; const passedThrough = context.passedThrough || []; const parsedArgs = strategies.args(parsedNode).map((x) => x.value); const varName = parsedArgs[0]; const restArgs = parsedArgs.slice(1); // check var not empty if (!varName) { if (node) { context.diagnostics.report(diagnostics.MISSING_VAR_IN_VALUE(), { node, word: getStringValue(parsedNode), }); } } else if (parsedArgs.length >= 1) { // override with value if (stVarOverride?.[varName]) { parsedNode.resolvedValue = stVarOverride?.[varName]; return; } // check cyclic const refUniqID = createUniqID(data.meta.source, varName); if (passedThrough.includes(refUniqID)) { // TODO: move diagnostic to original value usage instead of the end of the cyclic chain handleCyclicValues(context, passedThrough, refUniqID, data.node, value, parsedNode); return; } // resolve const resolvedSymbols = context.getResolvedSymbols(data.meta); const resolvedVar = resolvedSymbols.var[varName]; const resolvedVarSymbol = resolvedVar?.symbol; const possibleNonSTVarSymbol = STSymbol.get(context.meta, varName); if (resolvedVarSymbol) { const { outputValue, topLevelType, typeError } = context.evaluator.evaluateValue( { ...context, passedThrough: passedThrough.concat(refUniqID) }, { ...data, value: stripQuotation(resolvedVarSymbol.text), args: restArgs, node: resolvedVarSymbol.node, meta: resolvedVar.meta, rootArgument: varName, initialNode: node, } ); // report errors if (node) { const argsAsString = parsedArgs.join(', '); if (!typeError && !topLevelType && parsedArgs.length > 1) { context.diagnostics.report(diagnostics.MULTI_ARGS_IN_VALUE(argsAsString), { node, }); } } parsedNode.resolvedValue = context.evaluator.valueHook ? context.evaluator.valueHook(outputValue, varName, true, passedThrough) : outputValue; } else if (possibleNonSTVarSymbol) { const type = resolvedSymbols.mainNamespace[varName]; if (type === `js`) { const deepResolve = resolvedSymbols.js[varName]; const jsValue = deepResolve.symbol; if (typeof jsValue === 'string') { parsedNode.resolvedValue = context.evaluator.valueHook ? context.evaluator.valueHook(jsValue, varName, false, passedThrough) : jsValue; } else if (node) { // unsupported Javascript value // ToDo: provide actual exported id (default/named as x) context.diagnostics.report( diagnostics.CANNOT_USE_JS_AS_VALUE(typeof jsValue, varName), { node, word: varName, } ); } } else if (type) { // report mismatch type const deepResolve = resolvedSymbols[type][varName]; let finalResolve: CSSResolve = { _kind: `css`, meta: data.meta, symbol: possibleNonSTVarSymbol, }; if (deepResolve instanceof Array) { // take the deep resolved in order to // print the actual mismatched type finalResolve = deepResolve[deepResolve.length - 1]; } else if (deepResolve._kind === `css`) { finalResolve = deepResolve; } reportUnsupportedSymbolInValue(context, varName, finalResolve, node); } else if (node) { // report unknown var context.diagnostics.report(diagnostics.UNKNOWN_VAR(varName), { node, word: varName, }); } } else if (node) { context.diagnostics.report(diagnostics.UNKNOWN_VAR(varName), { node, word: varName, }); } } } export function resolveReferencedVarNames( context: Pick, initialName: string ) { const refNames = new Set(); const varsToCheck: { meta: StylableMeta; name: string }[] = [ { meta: context.meta, name: initialName }, ]; const checked = new Set(); while (varsToCheck.length) { const { meta, name } = varsToCheck.shift()!; const contextualId = meta.source + '/' + name; if (!checked.has(contextualId)) { checked.add(contextualId); refNames.add(name); const symbol = STSymbol.get(meta, name); switch (symbol?._kind) { case 'var': parseVarsFromExpr(symbol.text).forEach((refName) => varsToCheck.push({ meta, name: refName, }) ); break; case 'import': { const resolved = context.resolver.deepResolve(symbol); if (resolved?._kind === 'css' && resolved.symbol?._kind === 'var') { varsToCheck.push({ meta: resolved.meta, name: resolved.symbol.name }); } break; } } } } return refNames; } function reportUnsupportedSymbolInValue( context: FeatureTransformContext, name: string, resolve: CSSResolve, node: postcss.Node | undefined ) { const symbol = resolve.symbol; const errorKind = symbol._kind === 'class' && symbol[`-st-root`] ? 'stylesheet' : symbol._kind; if (node) { context.diagnostics.report(diagnostics.CANNOT_USE_AS_VALUE(errorKind, name), { node, word: name, }); } } function handleCyclicValues( context: FeatureTransformContext, passedThrough: string[], refUniqID: string, node: postcss.Node | undefined, value: string, parsedNode: ParsedValue ) { if (node) { const cyclicChain = passedThrough.map((variable) => variable || ''); cyclicChain.push(refUniqID); context.diagnostics.report(diagnostics.CYCLIC_VALUE(cyclicChain), { node, word: refUniqID, // ToDo: check word is path+var and not var name }); } return stringifyFunction(value, parsedNode); } function createUniqID(source: string, varName: string) { return `${source}: ${varName}`; }