File size: 14,278 Bytes
e92b981 2d39154 0c4637f e92b981 0c4637f e92b981 2d39154 e92b981 2d39154 ee84700 2d39154 ee84700 2d39154 ee84700 2d39154 e92b981 2d39154 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | /* analysis.js - pure analysis over .dssl text. No vscode imports here; the
* extension layer feeds it text and offsets, tests feed it strings.
* The point of this module: diagnostics come from the REAL parser and
* resolver (the same code the browser runs), and completions come from the
* real token registry folded across the workspace. */
import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
import { GLASS_WORDS, TILT_WORDS } from './engine/effects.js';
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, BLEND_MODES } from './engine/texture.js';
import { GLOW_KEYS, PARTICLE_KEYS, SHAPES, FROM_WORDS, DIR_WORDS } from './engine/aura.js';
import { SLIDER_CLAUSES, THUMB_SHAPES } from './engine/slider.js';
/* ---- the 0.9.0 vocabulary: what to offer after `texture:`, `aura-glow:`, `slider:` ... ----- */
const AURA_PROP = /^(aura|aura-glow|aura-particles?|glow-particles?|aura-glow-particles?)$/;
function domainItems(prop) {
const items = [];
const add = (label, detail, documentation, kind = 'keyword') => items.push({ label, kind, detail, documentation });
if (prop === 'texture') {
for (const n of PRESET_NAMES) add(n, 'texture preset', PRESETS[n].doc, 'value');
for (const g of GENERATORS) add(g, 'texture generator', 'start from scratch with this generator, then add settings');
for (const [k, d] of Object.entries(TEXTURE_KEYS)) add(k, 'texture setting', d.doc);
for (const b of BLEND_MODES) add(b, 'blend mode', 'how the texture mixes with what is under it');
} else if (prop === 'glass') {
for (const [k, d] of Object.entries(GLASS_WORDS)) add(k, 'glass setting', d);
add('none', 'clears', 'glass: none removes the effect');
} else if (prop === 'tilt') {
for (const [k, d] of Object.entries(TILT_WORDS)) add(k, 'tilt setting', d);
add('none', 'clears', 'tilt: none removes the effect');
} else if (prop === 'slider') {
for (const [k, usage] of Object.entries(SLIDER_CLAUSES)) add(k, 'slider setting', usage);
for (const t of THUMB_SHAPES) add(t, 'thumb shape', 'shape ' + t);
} else if (AURA_PROP.test(prop)) {
const glowOnly = prop === 'aura-glow';
const partsOnly = /particle/.test(prop) && prop !== 'aura-glow-particle' && prop !== 'aura-glow-particles';
if (prop === 'aura') { add('glow', 'aura layer', 'a soft light around the element'); add('particles', 'aura layer', 'things that leave the element'); add('none', 'clears', 'remove the whole aura'); }
if (!partsOnly) for (const [k, d] of Object.entries(GLOW_KEYS)) add(k, 'glow setting', d.doc);
if (!glowOnly) for (const [k, d] of Object.entries(PARTICLE_KEYS)) add(k, 'particle setting', d.doc);
if (!glowOnly) {
for (const s of SHAPES) add(s, 'particle shape', 'shape ' + s);
for (const f of FROM_WORDS) add(f, 'where particles are born', 'from ' + f);
for (const d of Object.keys(DIR_WORDS)) add(d, 'direction', 'dir ' + d);
}
for (const n of PRESET_NAMES) add(`texture(${n})`, 'texture inside an aura', PRESETS[n].doc);
}
return items;
}
function domainHover(word) {
if (PRESETS[word]) return `**${word}** _texture preset_ \u2014 ${PRESETS[word].doc}. Use it as \`texture: ${word}\`.`;
if (GENERATORS.includes(word)) return `**${word}** _texture generator_ \u2014 start from scratch with it, then add settings.`;
if (TEXTURE_KEYS[word]) return `**${word}** _texture setting_ \u2014 ${TEXTURE_KEYS[word].doc}`;
if (GLOW_KEYS[word]) return `**${word}** _glow setting_ \u2014 ${GLOW_KEYS[word].doc}`;
if (PARTICLE_KEYS[word]) return `**${word}** _particle setting_ \u2014 ${PARTICLE_KEYS[word].doc}`;
if (SLIDER_CLAUSES[word]) return `**${word}** _slider setting_ \u2014 \`${SLIDER_CLAUSES[word]}\``;
return null;
}
/* ---- workspace analysis ------------------------------------------------ */
/* docs: Map<absolute path, text>. Fold order: tokens*.dssl first, then
* alphabetical - the same convention the site uses (tokens first). */
export function analyzeWorkspace(docs) {
const paths = [...docs.keys()].sort((a, b) => {
const ta = /tokens[^/]*\.dssl$/.test(a) ? 0 : 1;
const tb = /tokens[^/]*\.dssl$/.test(b) ? 0 : 1;
return ta - tb || a.localeCompare(b);
});
const registry = { ...DEFAULT_TOKENS };
const motions = new Set();
const behaviors = new Set();
const perFile = new Map();
for (const p of paths) {
const text = docs.get(p);
const { ast, errors: parseErrors } = parse(text, p);
const doc = resolve(ast, p, { ...registry });
for (const [k, v] of Object.entries(doc.tokens || {})) registry[k] = v;
for (const m of Object.keys(doc.motions || {})) motions.add(m);
collectBehaviors(text, behaviors);
perFile.set(p, { errors: [...parseErrors, ...doc.errors], warnings: doc.warnings || [], doc });
}
return { registry, motions, behaviors, perFile };
}
function collectBehaviors(text, into) {
const re = /^\s*behavior\s+([\w-]+)/gm;
let m;
while ((m = re.exec(text))) into.add(m[1]);
}
/* ---- diagnostics ------------------------------------------------------- */
/* Returns [{ line (0-based), col, message, severity: 'error'|'warning', hint, source }] */
export function diagnosticsFor(analysis, path) {
const file = analysis.perFile.get(path);
if (!file) return [];
const out = [];
for (const e of file.errors) {
out.push({
line: Math.max(0, (e.line ?? 1) - 1),
col: 0,
message: e.message + (e.hint ? ` (did you mean "${e.hint}"?)` : ''),
severity: 'error',
hint: e.hint,
source: 'dssl',
});
}
for (const w of file.warnings) {
out.push({
line: Math.max(0, (w.line ?? 1) - 1),
col: 0,
message: w.message + (w.hint ? ` ${w.hint}` : ''),
severity: 'warning',
hint: w.hint,
source: 'dssl',
});
}
return out;
}
/* ---- context detection ------------------------------------------------- */
/* Strip comments and strings so brace/paren tracking and line inspection
* never trip on them. Returns masked text (same offsets). */
function maskCode(text) {
let out = '';
let i = 0;
let mode = null; // 'line' | 'block' | 'dq' | 'sq'
while (i < text.length) {
const c = text[i];
const n = text[i + 1];
if (mode === null) {
if (c === '/' && n === '/') { mode = 'line'; out += ' '; i += 2; continue; }
if (c === '/' && n === '*') { mode = 'block'; out += ' '; i += 2; continue; }
if (c === '"') { mode = 'dq'; out += ' '; i += 1; continue; }
if (c === "'") { mode = 'sq'; out += ' '; i += 1; continue; }
out += c; i += 1; continue;
}
if (mode === 'line') { if (c === '\n') { mode = null; out += '\n'; } else out += ' '; i += 1; continue; }
if (mode === 'block') { if (c === '*' && n === '/') { mode = null; out += ' '; i += 2; } else { out += c === '\n' ? '\n' : ' '; i += 1; } continue; }
// strings: terminate or newline kills them
if ((mode === 'dq' && c === '"') || (mode === 'sq' && c === "'")) { mode = null; out += ' '; i += 1; continue; }
if (c === '\n') { mode = null; out += '\n'; i += 1; continue; }
out += ' '; i += 1;
}
return out;
}
/* Returns { depth, inDecl, prop, valuePrefix, lineStart, lineToCursor } */
export function contextAt(text, offset) {
const masked = maskCode(text.slice(0, offset));
let depth = 0;
let paren = 0;
for (const c of masked) {
if (c === '{') depth++;
else if (c === '}') depth = Math.max(0, depth - 1);
else if (c === '(') paren++;
else if (c === ')') paren = Math.max(0, paren - 1);
}
const nl = masked.lastIndexOf('\n');
const lineToCursor = masked.slice(nl + 1);
const tokenDot = lineToCursor.match(/([\w-]+)\.([\w-]*)$/);
// declaration line: "prop: value..." anchored at line start, but a line
// that also opens a block ("a:hover {") is a selector, not a declaration
// 0.12.0: the declaration is the part after the last "{", ";" or "}" on the line, so `.t { texture: |` and
// `.a { color: red; background: |` complete values too. Before this only a declaration that started its line did.
const cut = Math.max(lineToCursor.lastIndexOf('{'), lineToCursor.lastIndexOf(';'), lineToCursor.lastIndexOf('}'));
const statement = cut >= 0 ? lineToCursor.slice(cut + 1) : lineToCursor;
let declMatch = statement.match(/^(\s*)(-{0,2}[.\w-]+)\s*:\s*([\s\S]*)$/);
if (declMatch && declMatch[3].includes('{')) declMatch = null;
// the value prefix is the trailing word segment: after "gap " (typed +
// space) it is empty, so completions re-open for the next word
const valueRaw = declMatch ? declMatch[3] : '';
const tail = valueRaw.match(/([^\s{;,]*)$/);
return {
depth,
paren,
inDecl: !!declMatch,
prop: declMatch ? declMatch[2] : null,
valuePrefix: tail ? tail[0] : '',
tokenGroup: tokenDot ? tokenDot[1] : null,
tokenPartial: tokenDot ? tokenDot[2] : null,
lineToCursor,
};
}
/* ---- completions ------------------------------------------------------- */
/* kind strings map to vscode.CompletionItemKind in extension.js */
export function completionsAt(analysis, path, text, offset) {
const ctx = contextAt(text, offset);
const items = [];
// token dot-completion: color., space., anything the registry knows
if (ctx.tokenGroup) {
const prefix = ctx.tokenGroup + '.';
const seen = new Set();
for (const key of Object.keys(analysis.registry)) {
if (key.startsWith(prefix)) {
const rest = key.slice(prefix.length);
if (!rest.includes('.') && !seen.has(rest)) {
seen.add(rest);
items.push({
label: rest,
kind: 'value',
detail: String(analysis.registry[key]),
documentation: `token ${prefix}${rest} = ${analysis.registry[key]}`,
});
}
}
}
if (items.length) return { items, context: 'token' };
}
// inside a declaration: values
if (ctx.inDecl) {
const prop = (ctx.prop || '').replace(/^-/, '');
// motions first for animate:, plus the animate-only grammar words
if (prop === 'animate') {
for (const m of analysis.motions) {
items.push({ label: m, kind: 'value', detail: 'motion', documentation: `motion defined in the workspace` });
}
for (const k of ['infinite', 'alternate', 'reverse', 'alternate-reverse', 'snap', 'essential', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'forwards', 'backwards', 'both']) {
items.push({ label: k, kind: 'keyword', detail: 'animate option', documentation: ANIMATE_EXTRAS[k] || 'animate option' });
}
}
// the words that belong to THIS property (presets after texture:, settings after glass:) sort first, then tokens, then the long
// list of generic CSS keywords; otherwise the editor buries `oak` under a hundred vendor keywords
const mark = (list, sort) => { for (const it of list) it.sort = sort; return list; };
mark(items, '0');
items.push(...mark(domainItems(prop), '0'));
for (const key of Object.keys(analysis.registry)) {
items.push({ label: key, kind: 'value', detail: 'token = ' + analysis.registry[key], documentation: `token ${key} = ${analysis.registry[key]}`, sort: '1' });
}
for (const k of CSS_KEYWORDS) {
items.push({ label: k, kind: 'keyword', detail: 'keyword', sort: '2' });
}
return { items, context: 'value' };
}
// property / block position
for (const p of PROPERTY_NAMES) {
items.push({ label: p, kind: 'property', detail: SEMANTIC[p] ? 'semantic' : 'css', documentation: SEMANTIC[p] || CSS[p] });
}
for (const [s, doc] of Object.entries(STATES)) {
items.push({ label: s, kind: 'block', detail: 'state block', documentation: doc });
}
for (const [b, doc] of Object.entries(BLOCKS)) {
items.push({ label: b, kind: 'block', detail: 'block', documentation: doc });
}
for (const [a, doc] of Object.entries(AT_RULES)) {
items.push({ label: a, kind: 'block', detail: 'at-rule', documentation: doc });
}
for (const b of analysis.behaviors) {
items.push({ label: 'use ' + b, kind: 'keyword', detail: 'behavior', documentation: 'apply the behavior "' + b + '"' });
}
for (const p of PSEUDO_ELEMENTS) {
items.push({ label: '::' + p, kind: 'keyword', detail: 'pseudo-element', documentation: 'compiles to real shadow CSS' });
}
return { items, context: 'property' };
}
const ANIMATE_EXTRAS = {
infinite: 'loop forever',
alternate: 'ease back on every other cycle',
snap: 'stepped timing',
essential: 'run even under reduced-motion',
};
function startsWith(s, prefix) {
if (!prefix) return true;
return s.startsWith(prefix) || s.includes(prefix);
}
/* ---- hovers ------------------------------------------------------------ */
export function hoverAt(analysis, path, text, offset) {
const before = text.slice(0, offset);
const after = text.slice(offset);
const m = before.match(/[\w.@-]+$/);
const n = after.match(/^[\w.-]*/);
const word = (m ? m[0] : '') + (n ? n[0] : '');
if (!word) return null;
const clean = word.replace(/^@/, '');
// token (possibly dotted)
if (clean.includes('.')) {
const v = analysis.registry[clean];
if (v !== undefined) {
return { contents: `**${clean}** \u2192 \`${v}\`\n\n_design token_ (value resolved from the workspace registry)` };
}
}
if (SEMANTIC[clean]) return { contents: `**${clean}** \u2014 ${SEMANTIC[clean]}` };
if (STATES[clean]) return { contents: `**${clean}** _state_ \u2014 ${STATES[clean]}` };
if (BLOCKS[clean]) return { contents: `**${clean}** \u2014 ${BLOCKS[clean]}` };
if (AT_RULES[clean]) return { contents: `**${clean}** \u2014 ${AT_RULES[clean]}` };
if (CSS[clean]) return { contents: `**${clean}** \u2014 ${CSS[clean]}` };
const dh = domainHover(clean);
if (dh) return { contents: dh };
if (analysis.motions.has(clean)) {
return { contents: `**${clean}** _motion_ \u2014 keyframe animation. Run with \`animate: ${clean} 600 ease-out\`.` };
}
if (analysis.behaviors.has(clean)) {
return { contents: `**${clean}** _behavior_ \u2014 apply with \`use ${clean}\`.` };
}
return null;
}
|