From 81d593ca80c9fd75b3214e149a05ab14c7d132fc Mon Sep 17 00:00:00 2001 From: Geordie Korper Date: Tue, 19 Aug 2025 22:18:25 -0400 Subject: [PATCH 1/4] feat: add outline support for AppleScript and JXA - Add document symbol providers for outline view - Implement function/handler detection and parsing - Add activation events for AppleScript and JXA languages - Enhance documentation for FunctionBlock interface --- package.json | 6 +- src/index.ts | 13 +- src/outline.ts | 754 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 src/outline.ts diff --git a/package.json b/package.json index c8dd192..ceb9bbe 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,11 @@ "vscode": "^1.85.0" }, "categories": ["Programming Languages", "Snippets", "Other"], - "activationEvents": ["onLanguage:javascript"], + "activationEvents": [ + "onLanguage:javascript", + "onLanguage:applescript", + "onLanguage:jxa" + ], "contributes": { "configurationDefaults": { "[applescript]": { diff --git a/src/index.ts b/src/index.ts index a71ec06..4f61705 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,16 @@ -import { type ExtensionContext, commands } from 'vscode'; +import { type ExtensionContext, commands, languages } from 'vscode'; import { osacompile, osascript } from './osa.ts'; +import { appleScriptSymbolProvider, jxaSymbolProvider } from './outline.ts'; import { pick } from './processes.ts'; import { createBuildTask } from './task.ts'; +/** + * Activate the VS Code extension. + * + * This registers editor/command handlers for AppleScript and JXA workflows + * (run, compile, build task creation, termination) and hooks the document + * symbol providers for the `applescript` and `jxa` languages. + */ async function activate(context: ExtensionContext): Promise { context.subscriptions.push( /** @@ -66,6 +74,9 @@ async function activate(context: ExtensionContext): Promise { commands.registerTextEditorCommand('extension.jxa.terminateProcess', async () => { await pick(); }), + + languages.registerDocumentSymbolProvider({ language: 'applescript' }, appleScriptSymbolProvider), + languages.registerDocumentSymbolProvider({ language: 'jxa' }, jxaSymbolProvider), ); } diff --git a/src/outline.ts b/src/outline.ts new file mode 100644 index 0000000..141d3db --- /dev/null +++ b/src/outline.ts @@ -0,0 +1,754 @@ +import { + DocumentSymbol, + type DocumentSymbolProvider, + Range, + type SymbolInformation, + SymbolKind, + type TextDocument, + commands, + workspace, +} from 'vscode'; + +/** + * A parsed function/tell block as returned by the scanner. + * + * Declared as an exported interface so documentation tools (TypeDoc) will + * present properties in the specified order (name, start, end, type). + */ +export interface FunctionBlock { + /** Human-friendly name (handler name or tell display). */ + name: string; + /** Start offset in the document (character index). */ + start: number; + /** End offset in the document (character index). */ + end: number; + /** Block type: 'handler' for on/to, or 'tell' for tell blocks. */ + type: 'handler' | 'tell'; +} + +/** + * Parse handler and tell blocks from a TextDocument using a stack-based scanner. + * + * This scanner walks the document line-by-line and maintains a stack of open + * regions. Handlers ("on"/"to") and "tell" blocks are recorded with their + * start offsets when they are opened and converted into result entries when + * their matching "end" is encountered. + * + * The algorithm is robust to nested blocks like `tell`, `try`, `if` and will + * correctly ignore `on error` clauses that are part of `try` blocks (these are + * not top-level handlers). Comment lines starting with `--` are skipped. + * + * Returns an array of blocks each with { name, start, end, type } describing + * the symbol name, start offset, end offset, and whether it's a 'handler' or + * a 'tell' block. + */ +export function parseFunctionBlocks(document: TextDocument) { + const blockOpeners = ['if', 'repeat', 'try', 'considering', 'ignoring', 'using terms', 'with timeout'] as const; + const blockEndQualifiers = ['try', 'if', 'repeat', 'considering', 'ignoring', 'using terms', 'timeout'] as const; + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // The helper implementations are exported above. + // Build a regex for block openers used by the scanner + const blockOpenRe = new RegExp(`^\\s*(?:${blockOpeners.map(escapeRegex).join('|')})\\b`, 'i'); + + const findLastIndex = (arr: T[], pred: (t: T) => boolean): number => { + for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i] as T)) return i; + return -1; + }; + + const functionBlocks: FunctionBlock[] = []; + const stack: Array<{ type: 'handler' | 'tell' | 'block'; name?: string; kind?: string; start: number }> = []; + + for (let ln = 0; ln < document.lineCount; ln++) { + const lineRange = document.lineAt(ln).range; + const lineText = document.getText(lineRange); + if (/^\s*--/.test(lineText)) continue; + const openerHandler = /^\s*(?:on|to)\s+(\w+)/i.exec(lineText); + if (openerHandler) { + const hName = (openerHandler[1] ?? '').toLowerCase(); + const inTry = stack.some((s) => s.type === 'block' && (s.kind ?? '') === 'try'); + if (!(hName === 'error' && inTry)) { + stack.push({ type: 'handler', name: openerHandler[1] ?? '', start: document.offsetAt(lineRange.start) }); + continue; + } + } + const tellOpen = /^\s*tell\s+(.*)$/i.exec(lineText); + if (tellOpen) { + const rest = (tellOpen[1] ?? '').trim(); + const display = rest ? `tell ${rest}` : 'tell'; + stack.push({ type: 'tell', name: display, start: document.offsetAt(lineRange.start) }); + continue; + } + const blockOpen = blockOpenRe.exec(lineText); + if (blockOpen) { + const kind = (blockOpen[0] ?? '').trim().toLowerCase(); + stack.push({ type: 'block', kind, start: document.offsetAt(lineRange.start) }); + continue; + } + const endMatch = /^\s*end(?:\s+([\w\s]+))?\b/i.exec(lineText); + if (endMatch) { + const qualifier = (endMatch[1] ?? '').toLowerCase().trim(); + const closeAndRecord = (idx: number) => { + const item = stack.splice(idx, 1)[0]; + if (item && (item.type === 'handler' || item.type === 'tell')) { + functionBlocks.push({ + name: item.name ?? '', + start: item.start, + end: document.offsetAt(lineRange.end), + type: item.type, + }); + } + }; + + if (qualifier.startsWith('tell')) { + const idx = findLastIndex(stack, (s) => s.type === 'tell'); + if (idx !== -1) closeAndRecord(idx); + else if (stack.length) closeAndRecord(stack.length - 1); + continue; + } + if (blockEndQualifiers.some((k) => (k === 'timeout' ? qualifier.includes('timeout') : qualifier.startsWith(k)))) { + const idx = findLastIndex(stack, (s) => s.type === 'block'); + if (idx !== -1) stack.splice(idx, 1); + else if (stack.length) stack.pop(); + continue; + } + if (qualifier.length > 0) { + const idx = findLastIndex(stack, (s) => s.type === 'handler' && (s.name ?? '').toLowerCase() === qualifier); + if (idx !== -1) { + closeAndRecord(idx); + continue; + } + } + if (stack.length) closeAndRecord(stack.length - 1); + } + } + + functionBlocks.sort((a, b) => a.start - b.start); + return functionBlocks; +} + +/** + * Collect property declarations from text. + * + * Scans with `propertyRegex` and returns each match as { name, index }. + * Lines that are commented out (start with `--`) are ignored. The returned + * index is the character offset in the document where the match starts. + */ +export function collectProperties(text: string, document: TextDocument, propertyRegex: RegExp) { + const out: Array<{ name: string; index: number }> = []; + let m = propertyRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + const pos = document.positionAt(index); + const lt = document.lineAt(pos.line).text; + if (!/^\s*--/.test(lt)) out.push({ name, index }); + m = propertyRegex.exec(text); + } + return out; +} + +/** + * Collect variable assignment occurrences from text. + * + * Uses `varRegex` to find `set to` occurrences. Commented lines are + * ignored. Returns an array of { name, index } where index is the offset of + * the match in the document. + */ +export function collectVariables(text: string, document: TextDocument, varRegex: RegExp) { + const out: Array<{ name: string; index: number }> = []; + let m = varRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + const pos = document.positionAt(index); + const lt = document.lineAt(pos.line).text; + if (!/^\s*--/.test(lt)) out.push({ name, index }); + m = varRegex.exec(text); + } + return out; +} + +/** + * Build a simple containment tree from an array of blocks. + * + * Each block has start/end offsets; this routine assigns a `parent` index + * for nodes that are contained by an earlier block, and populates `children` + * arrays on parents. The input is expected to be sorted by start offset. + */ +export function buildNodeTree(blocks: FunctionBlock[]) { + const ns = blocks.map((b, i) => ({ ...b, idx: i, parent: -1 as number, children: [] as number[] })); + for (let i = 0; i < ns.length; i++) { + for (let j = i - 1; j >= 0; j--) { + const ni = ns[i]; + const nj = ns[j]; + if (!ni || !nj) continue; + if (nj.start <= ni.start && ni.end <= nj.end) { + ni.parent = j; + nj.children.push(i); + break; + } + } + } + return ns; +} + +/** + * Remove duplicate items based on `name`, keeping the earliest occurrence. + * + * Implementation detail: items are first sorted by their `index` and then + * filtered so that only the first seen name is included. This is useful for + * deduping symbol lists while preserving document order. + */ +export function dedupeByName(items: Array<{ name: string; index: number }>) { + const seen = new Set(); + const result: Array<{ name: string; index: number }> = []; + for (const it of items.sort((a, b) => a.index - b.index)) { + if (!seen.has(it.name)) { + seen.add(it.name); + result.push(it); + } + } + return result; +} + +/** + * Collect top-level entry points (bare calls) from a document. + * + * This scans the text for bare identifiers or identifier calls (e.g. `foo` or + * `foo()`) using `entryPointRegex`. Entries that appear inside handler ranges + * are ignored. Additionally, any function/tell block that has the same name + * will suppress an entry with that name (so handlers are not double-reported). + * + * The returned list is deduped by name (earliest occurrence kept) to avoid + * reporting repeated top-level calls. + */ +export function collectEntryPoints( + text: string, + document: TextDocument, + entryPointRegex: RegExp, + handlerRanges: Array<{ start: number; end: number }>, + functionBlocks: FunctionBlock[], +) { + // Gather raw matches first (may contain duplicates) + const out: Array<{ name: string; index: number }> = []; + let m = entryPointRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + const pos = document.positionAt(index); + const lt = document.lineAt(pos.line).text; + if (!/^\s*--/.test(lt)) { + const isTopLevel = !handlerRanges.some((r) => index > r.start && index < r.end); + if (isTopLevel && !functionBlocks.some((h) => h.name === name)) out.push({ name, index }); + } + m = entryPointRegex.exec(text); + } + // Return deduped entries (preserve earliest occurrence based on index) + return dedupeByName(out); +} + +/** + * Build variable symbols belonging to a node but not inside its child nodes. + */ +export function makeVarSymbolsForNode( + nodeIdx: number, + nodesArr: ReturnType, + variablesArr: Array<{ name: string; index: number }>, + document: TextDocument, +) { + const node = nodesArr[nodeIdx]; + if (!node) return [] as DocumentSymbol[]; + const childRanges = node.children + .map((ci) => nodesArr[ci]) + .filter((c): c is typeof node => Boolean(c)) + .map((c) => ({ start: c.start, end: c.end })); + const ownVars = variablesArr.filter( + (v) => v.index > node.start && v.index < node.end && !childRanges.some((r) => v.index > r.start && v.index < r.end), + ); + return dedupeByName(ownVars).map((v) => { + const varStart = document.positionAt(v.index); + const varLineEnd = document.lineAt(varStart.line).range.end; + return new DocumentSymbol( + v.name, + '', + SymbolKind.Variable, + new Range(varStart, varLineEnd), + new Range(varStart, varStart), + ); + }); +} + +/** + * Recursively build a DocumentSymbol for a function/tell node. + */ +export function makeFuncSymbol( + nodeIdx: number, + nodesArr: ReturnType, + variablesArr: Array<{ name: string; index: number }>, + document: TextDocument, +): DocumentSymbol { + const node = nodesArr[nodeIdx]; + if (!node) { + const p = document.positionAt(0); + return new DocumentSymbol('unknown', '', SymbolKind.Function, new Range(p, p), new Range(p, p)); + } + const start = document.positionAt(node.start); + const end = document.positionAt(node.end); + const sym = new DocumentSymbol(node.name, '', SymbolKind.Function, new Range(start, end), new Range(start, start)); + const childFuncSyms = node.children.map((ci) => makeFuncSymbol(ci, nodesArr, variablesArr, document)); + const varSyms = makeVarSymbolsForNode(nodeIdx, nodesArr, variablesArr, document); + sym.children = [...childFuncSyms, ...varSyms]; + return sym; +} + +/** + * Emit final DocumentSymbol array from collected pieces. + */ +export function emitSymbols( + document: TextDocument, + properties: Array<{ name: string; index: number }>, + variables: Array<{ name: string; index: number }>, + entryPoints: Array<{ name: string; index: number }>, + nodes: ReturnType, +) { + const out: DocumentSymbol[] = []; + for (const p of properties) { + const propStart = document.positionAt(p.index); + const propLineEnd = document.lineAt(propStart.line).range.end; + out.push( + new DocumentSymbol( + p.name, + '', + SymbolKind.Property, + new Range(propStart, propLineEnd), + new Range(propStart, propStart), + ), + ); + } + const handlerRanges = nodes.map((n) => ({ start: n.start, end: n.end })); + const globals = variables.filter((v) => !handlerRanges.some((r) => v.index > r.start && v.index < r.end)); + for (const v of dedupeByName(globals)) { + const varStart = document.positionAt(v.index); + const varLineEnd = document.lineAt(varStart.line).range.end; + out.push( + new DocumentSymbol( + v.name, + '', + SymbolKind.Variable, + new Range(varStart, varLineEnd), + new Range(varStart, varStart), + ), + ); + } + for (const e of entryPoints) { + const entryStart = document.positionAt(e.index); + const entryLineEnd = document.lineAt(entryStart.line).range.end; + out.push( + new DocumentSymbol( + e.name, + '', + SymbolKind.Event, + new Range(entryStart, entryLineEnd), + new Range(entryStart, entryStart), + ), + ); + } + for (let i = 0; i < nodes.length; i++) { + if (nodes[i]?.parent === -1) out.push(makeFuncSymbol(i, nodes, variables, document)); + } + return out; +} + +/** + * AppleScript Document Symbol Provider. + * + * This provider implements a line-based, stack-driven parser that extracts + * AppleScript "handlers" (on/to), "tell" blocks and a set of top-level + * symbols (properties, variables, and bare entry-point calls). It returns an + * array of `DocumentSymbol` objects suitable for the VS Code outline/view. + * + * Key behaviors: + * - Skips lines that are commented with `--`. + * - Detects handler openers (`on` / `to`) and closes them on matching `end`. + * - Treats `on error` inside `try` as part of the `try` block (not a top-level handler). + * - Recognizes common block keywords (`if`, `repeat`, `try`, `using terms`, `with timeout`, etc.) + * to properly nest and ignore non-handler blocks. + * - Collects `property` declarations and `set to` assignments and nests + * variables under the handler they belong to while exposing globals at top-level. + * - Collects bare entry points (e.g. `myHandler` or `myHandler()`) outside of handlers. + * - Dedupe is applied where appropriate to keep the earliest occurrence of repeated names. + * + * The implementation intentionally uses a conservative line scanner rather + * than a full parser to keep the provider fast and tolerant of partial/invalid + * code while giving useful outline symbols. + */ +// AppleScript Document Symbol Provider (Outline) +export const appleScriptSymbolProvider: DocumentSymbolProvider = { + provideDocumentSymbols(document) { + const text = document.getText(); + const propertyRegex = /^\s*property\s+(\w+)\s*:/gm; + // Allow bare calls with or without parentheses, e.g. myHandler or myHandler() + const entryPointRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(\s*\))?\s*$/gm; + const varRegex = /^\s*set\s+(\w+)\s+to\b/gm; + + // Centralize AppleScript block keywords + const blockOpeners = ['if', 'repeat', 'try', 'considering', 'ignoring', 'using terms', 'with timeout'] as const; + // For "end" lines, AppleScript uses e.g. "end timeout" rather than "end with timeout" + const blockEndQualifiers = ['try', 'if', 'repeat', 'considering', 'ignoring', 'using terms', 'timeout'] as const; + + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const blockOpenRe = new RegExp(`^\\s*(?:${blockOpeners.map(escapeRegex).join('|')})\\b`, 'i'); + + // Helpers + const findLastIndex = (arr: T[], pred: (t: T) => boolean): number => { + for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i] as T)) return i; + return -1; + }; + const isCommentAtOffset = (offset: number): boolean => { + const pos = document.positionAt(offset); + const lt = document.lineAt(pos.line).text; + return /^\s*--/.test(lt); + }; + + // Build function/tell blocks using a stack-based line scanner for accurate ranges + const parseFunctionBlocks = (): Array<{ name: string; start: number; end: number; type: 'handler' | 'tell' }> => { + const functionBlocks: Array<{ name: string; start: number; end: number; type: 'handler' | 'tell' }> = []; + const stack: Array<{ type: 'handler' | 'tell' | 'block'; name?: string; kind?: string; start: number }> = []; + for (let ln = 0; ln < document.lineCount; ln++) { + const lineRange = document.lineAt(ln).range; + const lineText = document.getText(lineRange); + // Skip line comments + if (/^\s*--/.test(lineText)) { + continue; + } + const openerHandler = /^\s*(?:on|to)\s+(\w+)/i.exec(lineText); + if (openerHandler) { + const hName = (openerHandler[1] ?? '').toLowerCase(); + // Skip 'on error' inside a try-block; it's not a standalone handler, but part of 'try...on error...end try' + const inTry = stack.some((s) => s.type === 'block' && (s.kind ?? '') === 'try'); + if (hName === 'error' && inTry) { + // Do not treat as a handler + } else { + stack.push({ type: 'handler', name: openerHandler[1] ?? '', start: document.offsetAt(lineRange.start) }); + continue; + } + } + const tellOpen = /^\s*tell\s+(.*)$/i.exec(lineText); + if (tellOpen) { + const rest = (tellOpen[1] ?? '').trim(); + const display = rest ? `tell ${rest}` : 'tell'; + stack.push({ type: 'tell', name: display, start: document.offsetAt(lineRange.start) }); + continue; + } + const blockOpen = blockOpenRe.exec(lineText); + if (blockOpen) { + const kind = (blockOpen[0] ?? '').trim().toLowerCase(); + stack.push({ type: 'block', kind, start: document.offsetAt(lineRange.start) }); + continue; + } + const endMatch = /^\s*end(?:\s+([\w\s]+))?\b/i.exec(lineText); + if (endMatch) { + const qualifier = (endMatch[1] ?? '').toLowerCase().trim(); + const closeAndRecord = (idx: number) => { + const item = stack.splice(idx, 1)[0]; + if (item && (item.type === 'handler' || item.type === 'tell')) { + functionBlocks.push({ + name: item.name ?? '', + start: item.start, + end: document.offsetAt(lineRange.end), + type: item.type, + }); + } + }; + + if (qualifier.startsWith('tell')) { + const idx = findLastIndex(stack, (s) => s.type === 'tell'); + if (idx !== -1) closeAndRecord(idx); + else if (stack.length) closeAndRecord(stack.length - 1); + continue; + } + if ( + blockEndQualifiers.some((k) => (k === 'timeout' ? qualifier.includes('timeout') : qualifier.startsWith(k))) + ) { + const idx = findLastIndex(stack, (s) => s.type === 'block'); + if (idx !== -1) stack.splice(idx, 1); + else if (stack.length) stack.pop(); + continue; + } + if (qualifier.length > 0) { + const idx = findLastIndex(stack, (s) => s.type === 'handler' && (s.name ?? '').toLowerCase() === qualifier); + if (idx !== -1) { + closeAndRecord(idx); + continue; + } + } + // Plain 'end' fallback + if (stack.length) closeAndRecord(stack.length - 1); + } + } + // Sort by start position + functionBlocks.sort((a, b) => a.start - b.start); + return functionBlocks; + }; + const functionBlocks = parseFunctionBlocks(); + + // Collect property declarations + const collectProperties = (): Array<{ name: string; index: number }> => { + const out: Array<{ name: string; index: number }> = []; + let m = propertyRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + if (!isCommentAtOffset(index)) out.push({ name, index }); + m = propertyRegex.exec(text); + } + return out; + }; + const properties = collectProperties(); + + // Collect variable assignments + const collectVariables = (): Array<{ name: string; index: number }> => { + const out: Array<{ name: string; index: number }> = []; + let m = varRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + if (!isCommentAtOffset(index)) out.push({ name, index }); + m = varRegex.exec(text); + } + return out; + }; + const variables = collectVariables(); + + // Build symbols, nesting variables under their handler, and add global variables as top-level + const handlerRanges: Array<{ start: number; end: number }> = functionBlocks.map((b) => ({ + start: b.start, + end: b.end, + })); + + // Build a tree of function/tell blocks (parent-child relations) + const buildNodeTree = (blocks: Array<{ name: string; start: number; end: number; type: 'handler' | 'tell' }>) => { + const ns = blocks.map((b, i) => ({ ...b, idx: i, parent: -1 as number, children: [] as number[] })); + for (let i = 0; i < ns.length; i++) { + for (let j = i - 1; j >= 0; j--) { + const ni = ns[i]; + const nj = ns[j]; + if (!ni || !nj) continue; + if (nj.start <= ni.start && ni.end <= nj.end) { + ni.parent = j; + nj.children.push(i); + break; + } + } + } + return ns; + }; + const nodes = buildNodeTree(functionBlocks); + + // Helper: dedupe by variable name, keeping first occurrence + const dedupeByName = (items: Array<{ name: string; index: number }>) => { + const seen = new Set(); + const result: Array<{ name: string; index: number }> = []; + for (const it of items.sort((a, b) => a.index - b.index)) { + if (!seen.has(it.name)) { + seen.add(it.name); + result.push(it); + } + } + return result; + }; + + /** + * Collect top-level entry points (bare calls) that are not inside handlers. + */ + const collectEntryPoints = (): Array<{ name: string; index: number }> => { + const out: Array<{ name: string; index: number }> = []; + let m = entryPointRegex.exec(text); + while (m !== null) { + const name = m[1] ?? ''; + const index = typeof m.index === 'number' ? m.index : 0; + if (!isCommentAtOffset(index)) { + const isTopLevel = !handlerRanges.some((r) => index > r.start && index < r.end); + if (isTopLevel && !functionBlocks.some((h) => h.name === name)) out.push({ name, index }); + } + m = entryPointRegex.exec(text); + } + return out; + }; + const entryPoints = collectEntryPoints(); + + /** + * Emit final DocumentSymbol array from collected pieces. + */ + const emitSymbols = () => { + const out: DocumentSymbol[] = []; + + // properties + for (const p of properties) { + const propStart = document.positionAt(p.index); + const propLineEnd = document.lineAt(propStart.line).range.end; + out.push( + new DocumentSymbol( + p.name, + '', + SymbolKind.Property, + new Range(propStart, propLineEnd), + new Range(propStart, propStart), + ), + ); + } + + // globals + const globals = variables.filter((v) => !handlerRanges.some((r) => v.index > r.start && v.index < r.end)); + for (const v of dedupeByName(globals)) { + const varStart = document.positionAt(v.index); + const varLineEnd = document.lineAt(varStart.line).range.end; + out.push( + new DocumentSymbol( + v.name, + '', + SymbolKind.Variable, + new Range(varStart, varLineEnd), + new Range(varStart, varStart), + ), + ); + } + + // entry points + for (const e of entryPoints) { + const entryStart = document.positionAt(e.index); + const entryLineEnd = document.lineAt(entryStart.line).range.end; + out.push( + new DocumentSymbol( + e.name, + '', + SymbolKind.Event, + new Range(entryStart, entryLineEnd), + new Range(entryStart, entryStart), + ), + ); + } + + // functions/tells + for (let i = 0; i < nodes.length; i++) { + if (nodes[i]?.parent === -1) { + out.push(makeFuncSymbol(i, nodes, variables)); + } + } + + return out; + }; + + const symbols = emitSymbols(); + + // Helper: build variable symbols belonging to a node but not inside its child nodes + function makeVarSymbolsForNode( + nodeIdx: number, + nodesArr: typeof nodes, + variablesArr: Array<{ name: string; index: number }>, + ) { + const node = nodesArr[nodeIdx]; + if (!node) return [] as DocumentSymbol[]; + const childRanges = node.children + .map((ci) => nodesArr[ci]) + .filter((c): c is typeof node => Boolean(c)) + .map((c) => ({ start: c.start, end: c.end })); + const ownVars = variablesArr.filter( + (v) => + v.index > node.start && v.index < node.end && !childRanges.some((r) => v.index > r.start && v.index < r.end), + ); + return dedupeByName(ownVars).map((v) => { + const varStart = document.positionAt(v.index); + const varLineEnd = document.lineAt(varStart.line).range.end; + return new DocumentSymbol( + v.name, + '', + SymbolKind.Variable, + new Range(varStart, varLineEnd), + new Range(varStart, varStart), + ); + }); + } + + // Recursively build function/tell symbols tree (pure helper) + function makeFuncSymbol( + nodeIdx: number, + nodesArr: typeof nodes, + variablesArr: Array<{ name: string; index: number }>, + ): DocumentSymbol { + const node = nodesArr[nodeIdx]; + if (!node) { + const p = document.positionAt(0); + return new DocumentSymbol('unknown', '', SymbolKind.Function, new Range(p, p), new Range(p, p)); + } + const start = document.positionAt(node.start); + const end = document.positionAt(node.end); + const sym = new DocumentSymbol( + node.name, + '', + SymbolKind.Function, + new Range(start, end), + new Range(start, start), + ); + const childFuncSyms = node.children.map((ci) => makeFuncSymbol(ci, nodesArr, variablesArr)); + const varSyms = makeVarSymbolsForNode(nodeIdx, nodesArr, variablesArr); + sym.children = [...childFuncSyms, ...varSyms]; + return sym; + } + + // Add top-level function/tell symbols + for (let i = 0; i < nodes.length; i++) { + if (nodes[i]?.parent === -1) { + symbols.push(makeFuncSymbol(i, nodes, variables)); + } + } + if (!Array.isArray(symbols)) { + return []; + } + return symbols.filter((s) => s instanceof DocumentSymbol); + }, +}; + +/** + * JXA (JavaScript for Automation) Document Symbol Provider. + * + * Instead of re-implementing a JS parser, this provider delegates to the + * built-in JavaScript Document Symbol Provider by creating a virtual + * JavaScript document with the same content and invoking + * `vscode.executeDocumentSymbolProvider` on it. The returned symbols may be + * either `DocumentSymbol[]` or `SymbolInformation[]` depending on the + * provider; this code converts `SymbolInformation[]` into a flat + * `DocumentSymbol[]` (using the symbol's location as both range and selectionRange). + * + * This approach leverages the editor's JavaScript tooling to provide a + * richer outline for JXA files without duplicating parsing logic. + */ +// JXA: delegate to JavaScript's outline by creating a virtual JS document with identical content +export const jxaSymbolProvider: DocumentSymbolProvider = { + async provideDocumentSymbols(document) { + const text = document.getText(); + const jsDoc = await workspace.openTextDocument({ language: 'javascript', content: text }); + const result = await commands.executeCommand<(DocumentSymbol | SymbolInformation)[] | undefined>( + 'vscode.executeDocumentSymbolProvider', + jsDoc.uri, + ); + if (!Array.isArray(result) || result.length === 0) return []; + // If already DocumentSymbols (presence of selectionRange), return as-is + const first = result[0] as DocumentSymbol | SymbolInformation; + if ((first as DocumentSymbol).selectionRange !== undefined) { + return result as DocumentSymbol[]; + } + // Convert SymbolInformation[] to DocumentSymbol[] (flat) + const infos = result as SymbolInformation[]; + return infos.map((s) => { + const range = s.location.range; + return new DocumentSymbol( + s.name ?? '', + s.containerName ?? '', + s.kind ?? SymbolKind.Function, + range, + new Range(range.start, range.start), + ); + }); + }, +}; From 9d12a791ffffef966f318c7f0bd923fbf298eedd Mon Sep 17 00:00:00 2001 From: Geordie Korper Date: Tue, 19 Aug 2025 22:46:40 -0400 Subject: [PATCH 2/4] fix: remove duplicate symbol generation in outline view The appleScriptSymbolProvider was adding function symbols twice: - Once inside the emitSymbols() function - Again after calling emitSymbols() This caused duplicate entries for all handlers/functions in the outline. --- src/outline.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/outline.ts b/src/outline.ts index 141d3db..af2bca6 100644 --- a/src/outline.ts +++ b/src/outline.ts @@ -639,8 +639,6 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { return out; }; - const symbols = emitSymbols(); - // Helper: build variable symbols belonging to a node but not inside its child nodes function makeVarSymbolsForNode( nodeIdx: number, @@ -696,12 +694,7 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { return sym; } - // Add top-level function/tell symbols - for (let i = 0; i < nodes.length; i++) { - if (nodes[i]?.parent === -1) { - symbols.push(makeFuncSymbol(i, nodes, variables)); - } - } + const symbols = emitSymbols(); if (!Array.isArray(symbols)) { return []; } From 797b2d572c125f020d634e52874714b931992147 Mon Sep 17 00:00:00 2001 From: Geordie Korper Date: Wed, 20 Aug 2025 11:05:12 -0400 Subject: [PATCH 3/4] fix: resolve lint errors and update biome config - Update Biome from 1.9.4 to 2.2.0 to fix config compatibility - Remove invalid organizeImports key from biome config - Fix map/forEach usage in processes.ts (lint error) - Fix function naming conflict in outline.ts (_makeFuncSymbol) - Apply import ordering and formatting fixes --- biome.jsonc | 8 ++--- package.json | 45 ++++++++++++++++++++++------ pnpm-lock.yaml | 76 ++++++++++++++++++++++++------------------------ src/index.ts | 2 +- src/outline.ts | 8 ++--- src/processes.ts | 2 +- 6 files changed, 83 insertions(+), 58 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index eabb756..993a997 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,10 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, + "root": false, + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "files": { - "ignore": ["lib/**", "node_modules/**", "pnpm-lockfile.yaml"] + "includes": ["**", "!**/lib", "!**/node_modules", "!**/pnpm-lockfile.yaml"] }, "linter": { "enabled": true, diff --git a/package.json b/package.json index ceb9bbe..494dc45 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,11 @@ "engines": { "vscode": "^1.85.0" }, - "categories": ["Programming Languages", "Snippets", "Other"], + "categories": [ + "Programming Languages", + "Snippets", + "Other" + ], "activationEvents": [ "onLanguage:javascript", "onLanguage:applescript", @@ -84,7 +88,11 @@ }, "applescript.defaultBuildTask": { "type": "string", - "enum": ["script", "bundle", "app"], + "enum": [ + "script", + "bundle", + "app" + ], "default": "script", "description": "Specify the default build task when creating a new task file", "order": 5 @@ -370,21 +378,38 @@ "languages": [ { "id": "applescript", - "aliases": ["AppleScript", "applescript"], - "extensions": [".applescript"], + "aliases": [ + "AppleScript", + "applescript" + ], + "extensions": [ + ".applescript" + ], "firstLine": "^#!/.*\\bosascript\\b", "configuration": "./config/applescript.configuration.json" }, { "id": "applescript.binary", - "aliases": ["Binary AppleScript", "applescript.binary", "scpt"], - "extensions": [".scpt"], + "aliases": [ + "Binary AppleScript", + "applescript.binary", + "scpt" + ], + "extensions": [ + ".scpt" + ], "configuration": "./config/applescript.configuration.json" }, { "id": "jxa", - "aliases": ["JavaScript for Automation (JXA)", "jxa"], - "extensions": [".jxa", ".jxainc"], + "aliases": [ + "JavaScript for Automation (JXA)", + "jxa" + ], + "extensions": [ + ".jxa", + ".jxainc" + ], "configuration": "./config/jxa.configuration.json" }, { @@ -424,7 +449,9 @@ "language": "applescript-injection", "scopeName": "markdown.applescript.codeblock", "path": "./syntaxes/codeblock.json", - "injectTo": ["text.html.markdown"], + "injectTo": [ + "text.html.markdown" + ], "embeddedLanguages": { "meta.embedded.block.applescript": "applescript" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81bc50b..0c25c99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: version: 0.4.0 devDependencies: '@biomejs/biome': - specifier: ^1.9.4 - version: 1.9.4 + specifier: ^2.2.0 + version: 2.2.0 '@commitlint/cli': specifier: ^19.8.0 version: 19.8.0(@types/node@20.17.31)(typescript@5.8.3) @@ -92,55 +92,55 @@ packages: resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@1.9.4': - resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + '@biomejs/biome@2.2.0': + resolution: {integrity: sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@1.9.4': - resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + '@biomejs/cli-darwin-arm64@2.2.0': + resolution: {integrity: sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@1.9.4': - resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + '@biomejs/cli-darwin-x64@2.2.0': + resolution: {integrity: sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@1.9.4': - resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + '@biomejs/cli-linux-arm64-musl@2.2.0': + resolution: {integrity: sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@1.9.4': - resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + '@biomejs/cli-linux-arm64@2.2.0': + resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@1.9.4': - resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + '@biomejs/cli-linux-x64-musl@2.2.0': + resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@1.9.4': - resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + '@biomejs/cli-linux-x64@2.2.0': + resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@1.9.4': - resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + '@biomejs/cli-win32-arm64@2.2.0': + resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@1.9.4': - resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + '@biomejs/cli-win32-x64@2.2.0': + resolution: {integrity: sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -1589,39 +1589,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@biomejs/biome@1.9.4': + '@biomejs/biome@2.2.0': optionalDependencies: - '@biomejs/cli-darwin-arm64': 1.9.4 - '@biomejs/cli-darwin-x64': 1.9.4 - '@biomejs/cli-linux-arm64': 1.9.4 - '@biomejs/cli-linux-arm64-musl': 1.9.4 - '@biomejs/cli-linux-x64': 1.9.4 - '@biomejs/cli-linux-x64-musl': 1.9.4 - '@biomejs/cli-win32-arm64': 1.9.4 - '@biomejs/cli-win32-x64': 1.9.4 - - '@biomejs/cli-darwin-arm64@1.9.4': + '@biomejs/cli-darwin-arm64': 2.2.0 + '@biomejs/cli-darwin-x64': 2.2.0 + '@biomejs/cli-linux-arm64': 2.2.0 + '@biomejs/cli-linux-arm64-musl': 2.2.0 + '@biomejs/cli-linux-x64': 2.2.0 + '@biomejs/cli-linux-x64-musl': 2.2.0 + '@biomejs/cli-win32-arm64': 2.2.0 + '@biomejs/cli-win32-x64': 2.2.0 + + '@biomejs/cli-darwin-arm64@2.2.0': optional: true - '@biomejs/cli-darwin-x64@1.9.4': + '@biomejs/cli-darwin-x64@2.2.0': optional: true - '@biomejs/cli-linux-arm64-musl@1.9.4': + '@biomejs/cli-linux-arm64-musl@2.2.0': optional: true - '@biomejs/cli-linux-arm64@1.9.4': + '@biomejs/cli-linux-arm64@2.2.0': optional: true - '@biomejs/cli-linux-x64-musl@1.9.4': + '@biomejs/cli-linux-x64-musl@2.2.0': optional: true - '@biomejs/cli-linux-x64@1.9.4': + '@biomejs/cli-linux-x64@2.2.0': optional: true - '@biomejs/cli-win32-arm64@1.9.4': + '@biomejs/cli-win32-arm64@2.2.0': optional: true - '@biomejs/cli-win32-x64@1.9.4': + '@biomejs/cli-win32-x64@2.2.0': optional: true '@commitlint/cli@19.8.0(@types/node@20.17.31)(typescript@5.8.3)': diff --git a/src/index.ts b/src/index.ts index 4f61705..92ded17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { type ExtensionContext, commands, languages } from 'vscode'; +import { commands, type ExtensionContext, languages } from 'vscode'; import { osacompile, osascript } from './osa.ts'; import { appleScriptSymbolProvider, jxaSymbolProvider } from './outline.ts'; import { pick } from './processes.ts'; diff --git a/src/outline.ts b/src/outline.ts index af2bca6..8b74596 100644 --- a/src/outline.ts +++ b/src/outline.ts @@ -1,11 +1,11 @@ import { + commands, DocumentSymbol, type DocumentSymbolProvider, Range, type SymbolInformation, SymbolKind, type TextDocument, - commands, workspace, } from 'vscode'; @@ -632,7 +632,7 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { // functions/tells for (let i = 0; i < nodes.length; i++) { if (nodes[i]?.parent === -1) { - out.push(makeFuncSymbol(i, nodes, variables)); + out.push(_makeFuncSymbol(i, nodes, variables)); } } @@ -669,7 +669,7 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { } // Recursively build function/tell symbols tree (pure helper) - function makeFuncSymbol( + function _makeFuncSymbol( nodeIdx: number, nodesArr: typeof nodes, variablesArr: Array<{ name: string; index: number }>, @@ -688,7 +688,7 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { new Range(start, end), new Range(start, start), ); - const childFuncSyms = node.children.map((ci) => makeFuncSymbol(ci, nodesArr, variablesArr)); + const childFuncSyms = node.children.map((ci) => _makeFuncSymbol(ci, nodesArr, variablesArr)); const varSyms = makeVarSymbolsForNode(nodeIdx, nodesArr, variablesArr); sym.children = [...childFuncSyms, ...varSyms]; return sym; diff --git a/src/processes.ts b/src/processes.ts index 7afcd51..81c8d9c 100644 --- a/src/processes.ts +++ b/src/processes.ts @@ -48,7 +48,7 @@ export async function pick() { if (pick) { const picks = Array.isArray(pick) ? pick : [pick]; - picks.map((item) => { + picks.forEach((item) => { const pid = item.detail.split(' ')[0]; kill(Number(pid)); From 37769bae39c1b8f16d4baff921c2afd813b18c82 Mon Sep 17 00:00:00 2001 From: Geordie Korper Date: Fri, 22 Aug 2025 13:47:15 -0400 Subject: [PATCH 4/4] fix: outline provider for JXA to parse full hierarhcy --- package.json | 5 +- src/index.ts | 3 +- src/outline-jxa.ts | 393 +++++++++++++++++++++++++++++++++++++++++++ src/outline.ts | 62 +------ tsdown.config.ts | 2 +- types/acorn-types.ts | 334 ++++++++++++++++++++++++++++++++++++ 6 files changed, 733 insertions(+), 66 deletions(-) create mode 100644 src/outline-jxa.ts create mode 100644 types/acorn-types.ts diff --git a/package.json b/package.json index 494dc45..6ef12b0 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,7 @@ "Other" ], "activationEvents": [ - "onLanguage:javascript", - "onLanguage:applescript", - "onLanguage:jxa" + "onLanguage:javascript" ], "contributes": { "configurationDefaults": { @@ -469,6 +467,7 @@ ] }, "dependencies": { + "acorn": "^8.15.0", "line-column": "^1.0.2", "vscode-get-config": "^0.4.0" }, diff --git a/src/index.ts b/src/index.ts index 92ded17..bed3a03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { commands, type ExtensionContext, languages } from 'vscode'; import { osacompile, osascript } from './osa.ts'; -import { appleScriptSymbolProvider, jxaSymbolProvider } from './outline.ts'; +import { appleScriptSymbolProvider } from './outline.ts'; +import { jxaSymbolProvider } from './outline-jxa.ts'; import { pick } from './processes.ts'; import { createBuildTask } from './task.ts'; diff --git a/src/outline-jxa.ts b/src/outline-jxa.ts new file mode 100644 index 0000000..b6b82f8 --- /dev/null +++ b/src/outline-jxa.ts @@ -0,0 +1,393 @@ +import type { Node } from "acorn"; +import { + DocumentSymbol, + type DocumentSymbolProvider, + Range, + SymbolKind, +} from "vscode"; +import type { + ArrowFunctionExpression, + BaseNode, + BlockStatement, + ClassDeclaration, + DoWhileStatement, + ForInStatement, + ForOfStatement, + ForStatement, + FunctionDeclaration, + FunctionExpression, + Identifier, + IfStatement, + Program, + Statement, + SwitchStatement, + TryStatement, + VariableDeclaration, + WhileStatement, + WithStatement, +} from "../types/acorn-types"; + +/** + * Creates a DocumentSymbol from an acorn AST node. + * + * @param node - The acorn AST node with location information + * @param name - The name to display for this symbol + * @param kind - The VS Code SymbolKind for this symbol + * @returns A DocumentSymbol with proper range and empty children array + */ +function createSymbol( + node: BaseNode, + name: string, + kind: SymbolKind, +): DocumentSymbol { + const range = new Range( + node.loc.start.line - 1, + node.loc.start.column, + node.loc.end.line - 1, + node.loc.end.column, + ); + const symbol = new DocumentSymbol(name, "", kind, range, range); + symbol.children = []; + return symbol; +} + +/** + * Processes the body of conditional statements and loops. + * + * Handles both block statements (with curly braces) and single statements. + * For block statements, processes all statements within the block. + * For single statements, wraps them in an array and processes them. + * + * @param stmt - The statement node (either BlockStatement or any other statement) + * @param parent - The parent DocumentSymbol to add nested symbols to + * @param processedNodes - WeakSet tracking already processed nodes to avoid duplicates + */ +function processConditionalBody( + stmt: Statement, + parent: DocumentSymbol, + processedNodes: WeakSet, +) { + if (stmt.type === "BlockStatement") { + const blockStmt = stmt as BlockStatement; + processBlockContent(blockStmt.body, parent, processedNodes); + } else { + processBlockContent([stmt], parent, processedNodes); + } +} + +/** + * Processes variable declaration statements to extract symbols. + * + * Handles const, let, and var declarations. Determines the appropriate + * SymbolKind based on the declaration type and initializer: + * - Functions (arrow or regular) become Function symbols with nested content + * - Classes become Class symbols + * - Constants become Constant symbols + * - Regular variables become Variable symbols + * + * @param node - The VariableDeclaration AST node + * @param parentArray - Either an array of symbols or a parent symbol with children + * @param processedNodes - WeakSet tracking already processed nodes to avoid duplicates + */ +function processVariableDeclaration( + node: VariableDeclaration, + parentArray: DocumentSymbol[] | DocumentSymbol, + processedNodes: WeakSet, +) { + const targetArray = Array.isArray(parentArray) + ? parentArray + : parentArray.children || []; + + for (const decl of node.declarations) { + // Skip destructuring patterns for now + if (decl.id.type !== "Identifier") continue; + + const id = decl.id as Identifier; + let kind = SymbolKind.Variable; + + // Determine the symbol kind based on the initializer + if (decl.init) { + if ( + decl.init.type === "ArrowFunctionExpression" || + decl.init.type === "FunctionExpression" + ) { + kind = SymbolKind.Function; + const funcSymbol = createSymbol(decl, id.name, kind); + targetArray.push(funcSymbol); + + // Process function body if it exists + const funcExpr = decl.init as + | ArrowFunctionExpression + | FunctionExpression; + if (funcExpr.body.type === "BlockStatement") { + processBlockContent(funcExpr.body.body, funcSymbol, processedNodes); + } + continue; + } + if (decl.init.type === "ClassExpression") { + kind = SymbolKind.Class; + } else if (node.kind === "const") { + kind = SymbolKind.Constant; + } + } else if (node.kind === "const") { + kind = SymbolKind.Constant; + } + + targetArray.push(createSymbol(decl, id.name, kind)); + } +} + +/** + * Recursively processes an array of statements to extract nested symbols. + * + * This is the core function that walks through the AST and builds the symbol hierarchy. + * It handles various JavaScript/JXA constructs including: + * - Function declarations (with nested content) + * - Variable declarations (const, let, var) + * - Control flow statements (if/else, for, while, try/catch, switch) + * - Block statements + * + * Uses a WeakSet to track processed nodes and avoid duplicate processing, + * which is important when dealing with nested structures. + * + * @param statements - Array of AST statement nodes to process + * @param parent - The parent DocumentSymbol to add discovered symbols to + * @param processedNodes - WeakSet tracking already processed nodes to avoid duplicates + */ +function processBlockContent( + statements: Statement[], + parent: DocumentSymbol, + processedNodes: WeakSet, +) { + for (const stmt of statements) { + // Skip if already processed + if (processedNodes.has(stmt)) continue; + processedNodes.add(stmt); + + switch (stmt.type) { + case "FunctionDeclaration": { + const funcDecl = stmt as FunctionDeclaration; + if (funcDecl.id) { + const funcSymbol = createSymbol( + funcDecl, + funcDecl.id.name, + SymbolKind.Function, + ); + parent.children?.push(funcSymbol); + + // Recursively process nested content + processBlockContent(funcDecl.body.body, funcSymbol, processedNodes); + } + break; + } + + case "VariableDeclaration": + processVariableDeclaration( + stmt as VariableDeclaration, + parent, + processedNodes, + ); + break; + + case "IfStatement": { + const ifStmt = stmt as IfStatement; + // Process if/else blocks + processConditionalBody(ifStmt.consequent, parent, processedNodes); + if (ifStmt.alternate) { + processConditionalBody(ifStmt.alternate, parent, processedNodes); + } + break; + } + + case "ForStatement": { + const forStmt = stmt as ForStatement; + processConditionalBody(forStmt.body, parent, processedNodes); + break; + } + + case "ForInStatement": { + const forInStmt = stmt as ForInStatement; + processConditionalBody(forInStmt.body, parent, processedNodes); + break; + } + + case "ForOfStatement": { + const forOfStmt = stmt as ForOfStatement; + processConditionalBody(forOfStmt.body, parent, processedNodes); + break; + } + + case "WhileStatement": { + const whileStmt = stmt as WhileStatement; + processConditionalBody(whileStmt.body, parent, processedNodes); + break; + } + + case "DoWhileStatement": { + const doWhileStmt = stmt as DoWhileStatement; + processConditionalBody(doWhileStmt.body, parent, processedNodes); + break; + } + + case "TryStatement": { + const tryStmt = stmt as TryStatement; + // Process try/catch/finally blocks + processBlockContent(tryStmt.block.body, parent, processedNodes); + if (tryStmt.handler) { + processBlockContent( + tryStmt.handler.body.body, + parent, + processedNodes, + ); + } + if (tryStmt.finalizer) { + processBlockContent(tryStmt.finalizer.body, parent, processedNodes); + } + break; + } + + case "SwitchStatement": { + const switchStmt = stmt as SwitchStatement; + // Process switch cases + for (const switchCase of switchStmt.cases) { + processBlockContent(switchCase.consequent, parent, processedNodes); + } + break; + } + + case "WithStatement": { + const withStmt = stmt as WithStatement; + processConditionalBody(withStmt.body, parent, processedNodes); + break; + } + + case "BlockStatement": { + const blockStmt = stmt as BlockStatement; + // Process nested block statements + processBlockContent(blockStmt.body, parent, processedNodes); + break; + } + } + } +} + +/** + * JXA (JavaScript for Automation) Document Symbol Provider. + * + * Uses the acorn JavaScript parser to build a proper symbol hierarchy for JXA files. + * This implementation manually walks the AST to extract symbols and maintain their + * hierarchical relationships, ensuring that: + * - Nested functions within classes are properly shown + * - Variables are scoped correctly to their containing functions + * - All JavaScript constructs (classes, methods, functions, variables) are recognized + * + * The provider handles various edge cases including: + * - Arrow functions and function expressions + * - Class declarations and expressions + * - Destructuring patterns (currently skipped) + * - Control flow statements with nested content + * - Duplicate symbol prevention using WeakSet tracking + */ +export const jxaSymbolProvider: DocumentSymbolProvider = { + async provideDocumentSymbols(document) { + try { + const text = document.getText(); + + // Use acorn to parse JavaScript/JXA code + const acorn = await import("acorn"); + + // Parse the code into an AST + const ast = acorn.parse(text, { + ecmaVersion: "latest", + sourceType: "script", // JXA behaves more like a script than a module + locations: true, + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowAwaitOutsideFunction: true, + }) as Program; + + const symbols: DocumentSymbol[] = []; + const processedNodes = new WeakSet(); + + // Process top-level declarations only + for (const node of ast.body) { + switch (node.type) { + case "ClassDeclaration": { + const classDecl = node as ClassDeclaration; + if (classDecl.id) { + const classSymbol = createSymbol( + classDecl, + classDecl.id.name, + SymbolKind.Class, + ); + symbols.push(classSymbol); + processedNodes.add(node); + + // Process class methods + for (const member of classDecl.body.body) { + if (member.type === "MethodDefinition" && member.key) { + const key = member.key as Identifier; + const methodName = key.name; + if (methodName) { + const methodSymbol = createSymbol( + member, + methodName, + member.kind === "constructor" + ? SymbolKind.Constructor + : SymbolKind.Method, + ); + classSymbol.children.push(methodSymbol); + + // Process method body + if (member.value.body) { + processBlockContent( + member.value.body.body, + methodSymbol, + processedNodes, + ); + } + } + } + } + } + break; + } + + case "FunctionDeclaration": { + const funcDecl = node as FunctionDeclaration; + if (funcDecl.id) { + const funcSymbol = createSymbol( + funcDecl, + funcDecl.id.name, + SymbolKind.Function, + ); + symbols.push(funcSymbol); + processedNodes.add(node); + + // Process function body + processBlockContent( + funcDecl.body.body, + funcSymbol, + processedNodes, + ); + } + break; + } + + case "VariableDeclaration": + processVariableDeclaration( + node as VariableDeclaration, + symbols, + processedNodes, + ); + break; + } + } + + return symbols; + } catch (error) { + console.error("Error parsing JXA file:", error); + return []; + } + }, +}; diff --git a/src/outline.ts b/src/outline.ts index 8b74596..6ab0f05 100644 --- a/src/outline.ts +++ b/src/outline.ts @@ -1,12 +1,9 @@ import { - commands, DocumentSymbol, type DocumentSymbolProvider, Range, - type SymbolInformation, SymbolKind, type TextDocument, - workspace, } from 'vscode'; /** @@ -561,21 +558,7 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { /** * Collect top-level entry points (bare calls) that are not inside handlers. */ - const collectEntryPoints = (): Array<{ name: string; index: number }> => { - const out: Array<{ name: string; index: number }> = []; - let m = entryPointRegex.exec(text); - while (m !== null) { - const name = m[1] ?? ''; - const index = typeof m.index === 'number' ? m.index : 0; - if (!isCommentAtOffset(index)) { - const isTopLevel = !handlerRanges.some((r) => index > r.start && index < r.end); - if (isTopLevel && !functionBlocks.some((h) => h.name === name)) out.push({ name, index }); - } - m = entryPointRegex.exec(text); - } - return out; - }; - const entryPoints = collectEntryPoints(); + const entryPoints = collectEntryPoints(text, document, entryPointRegex, handlerRanges, functionBlocks); /** * Emit final DocumentSymbol array from collected pieces. @@ -702,46 +685,3 @@ export const appleScriptSymbolProvider: DocumentSymbolProvider = { }, }; -/** - * JXA (JavaScript for Automation) Document Symbol Provider. - * - * Instead of re-implementing a JS parser, this provider delegates to the - * built-in JavaScript Document Symbol Provider by creating a virtual - * JavaScript document with the same content and invoking - * `vscode.executeDocumentSymbolProvider` on it. The returned symbols may be - * either `DocumentSymbol[]` or `SymbolInformation[]` depending on the - * provider; this code converts `SymbolInformation[]` into a flat - * `DocumentSymbol[]` (using the symbol's location as both range and selectionRange). - * - * This approach leverages the editor's JavaScript tooling to provide a - * richer outline for JXA files without duplicating parsing logic. - */ -// JXA: delegate to JavaScript's outline by creating a virtual JS document with identical content -export const jxaSymbolProvider: DocumentSymbolProvider = { - async provideDocumentSymbols(document) { - const text = document.getText(); - const jsDoc = await workspace.openTextDocument({ language: 'javascript', content: text }); - const result = await commands.executeCommand<(DocumentSymbol | SymbolInformation)[] | undefined>( - 'vscode.executeDocumentSymbolProvider', - jsDoc.uri, - ); - if (!Array.isArray(result) || result.length === 0) return []; - // If already DocumentSymbols (presence of selectionRange), return as-is - const first = result[0] as DocumentSymbol | SymbolInformation; - if ((first as DocumentSymbol).selectionRange !== undefined) { - return result as DocumentSymbol[]; - } - // Convert SymbolInformation[] to DocumentSymbol[] (flat) - const infos = result as SymbolInformation[]; - return infos.map((s) => { - const range = s.location.range; - return new DocumentSymbol( - s.name ?? '', - s.containerName ?? '', - s.kind ?? SymbolKind.Function, - range, - new Range(range.start, range.start), - ); - }); - }, -}; diff --git a/tsdown.config.ts b/tsdown.config.ts index 274335e..2f430c9 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ external: ['vscode'], format: 'cjs', minify: true, - noExternal: ['line-column', 'vscode-get-config'], + noExternal: ['acorn', 'line-column', 'vscode-get-config'], outDir: 'lib', platform: 'node', target: 'es2020', diff --git a/types/acorn-types.ts b/types/acorn-types.ts new file mode 100644 index 0000000..779caaa --- /dev/null +++ b/types/acorn-types.ts @@ -0,0 +1,334 @@ +/** + * Extended Acorn AST type definitions for the specific nodes we use. + * These extend the base acorn types with the properties we need to access. + */ + +import type { Node, SourceLocation } from "acorn"; + +export interface BaseNode extends Node { + loc: SourceLocation; +} + +export interface Identifier extends BaseNode { + type: "Identifier"; + name: string; +} + +export interface VariableDeclarator extends BaseNode { + type: "VariableDeclarator"; + id: Identifier | Pattern; + init?: Expression; +} + +export interface VariableDeclaration extends BaseNode { + type: "VariableDeclaration"; + declarations: VariableDeclarator[]; + kind: "const" | "let" | "var"; +} + +export interface BlockStatement extends BaseNode { + type: "BlockStatement"; + body: Statement[]; +} + +export interface FunctionDeclaration extends BaseNode { + type: "FunctionDeclaration"; + id: Identifier | null; + params: Pattern[]; + body: BlockStatement; +} + +export interface FunctionExpression extends BaseNode { + type: "FunctionExpression"; + id?: Identifier | null; + params: Pattern[]; + body: BlockStatement; +} + +export interface ArrowFunctionExpression extends BaseNode { + type: "ArrowFunctionExpression"; + params: Pattern[]; + body: BlockStatement | Expression; +} + +export interface ClassDeclaration extends BaseNode { + type: "ClassDeclaration"; + id: Identifier | null; + body: ClassBody; +} + +export interface ClassExpression extends BaseNode { + type: "ClassExpression"; + id?: Identifier | null; + body: ClassBody; +} + +export interface ClassBody extends BaseNode { + type: "ClassBody"; + body: MethodDefinition[]; +} + +export interface MethodDefinition extends BaseNode { + type: "MethodDefinition"; + key: Identifier | Expression; + value: FunctionExpression; + kind: "constructor" | "method" | "get" | "set"; + static: boolean; +} + +export interface IfStatement extends BaseNode { + type: "IfStatement"; + test: Expression; + consequent: Statement; + alternate?: Statement | null; +} + +export interface ForStatement extends BaseNode { + type: "ForStatement"; + init?: VariableDeclaration | Expression | null; + test?: Expression | null; + update?: Expression | null; + body: Statement; +} + +export interface ForInStatement extends BaseNode { + type: "ForInStatement"; + left: VariableDeclaration | Pattern; + right: Expression; + body: Statement; +} + +export interface ForOfStatement extends BaseNode { + type: "ForOfStatement"; + left: VariableDeclaration | Pattern; + right: Expression; + body: Statement; +} + +export interface WhileStatement extends BaseNode { + type: "WhileStatement"; + test: Expression; + body: Statement; +} + +export interface DoWhileStatement extends BaseNode { + type: "DoWhileStatement"; + body: Statement; + test: Expression; +} + +export interface TryStatement extends BaseNode { + type: "TryStatement"; + block: BlockStatement; + handler?: CatchClause | null; + finalizer?: BlockStatement | null; +} + +export interface CatchClause extends BaseNode { + type: "CatchClause"; + param?: Pattern | null; + body: BlockStatement; +} + +export interface SwitchStatement extends BaseNode { + type: "SwitchStatement"; + discriminant: Expression; + cases: SwitchCase[]; +} + +export interface SwitchCase extends BaseNode { + type: "SwitchCase"; + test?: Expression | null; + consequent: Statement[]; +} + +export interface WithStatement extends BaseNode { + type: "WithStatement"; + object: Expression; + body: Statement; +} + +export interface NewExpression extends BaseNode { + type: "NewExpression"; + callee: Expression | Identifier; + arguments: Expression[]; +} + +export interface Program extends BaseNode { + type: "Program"; + body: Statement[]; + sourceType: "script" | "module"; +} + +// Union types for categories +export type Statement = + | BlockStatement + | VariableDeclaration + | FunctionDeclaration + | ClassDeclaration + | IfStatement + | ForStatement + | ForInStatement + | ForOfStatement + | WhileStatement + | DoWhileStatement + | TryStatement + | SwitchStatement + | WithStatement + | ExpressionStatement + | ReturnStatement + | ThrowStatement + | BreakStatement + | ContinueStatement; + +export type Expression = + | Identifier + | FunctionExpression + | ArrowFunctionExpression + | ClassExpression + | NewExpression + | CallExpression + | MemberExpression + | AssignmentExpression + | BinaryExpression + | UnaryExpression + | UpdateExpression + | LogicalExpression + | ConditionalExpression + | ArrayExpression + | ObjectExpression + | Literal; + +export type Pattern = + | Identifier + | ObjectPattern + | ArrayPattern + | RestElement + | AssignmentPattern; + +// Additional types we reference but don't fully define +export interface ExpressionStatement extends BaseNode { + type: "ExpressionStatement"; + expression: Expression; +} + +export interface ReturnStatement extends BaseNode { + type: "ReturnStatement"; + argument?: Expression | null; +} + +export interface ThrowStatement extends BaseNode { + type: "ThrowStatement"; + argument: Expression; +} + +export interface BreakStatement extends BaseNode { + type: "BreakStatement"; + label?: Identifier | null; +} + +export interface ContinueStatement extends BaseNode { + type: "ContinueStatement"; + label?: Identifier | null; +} + +export interface CallExpression extends BaseNode { + type: "CallExpression"; + callee: Expression; + arguments: Expression[]; +} + +export interface MemberExpression extends BaseNode { + type: "MemberExpression"; + object: Expression; + property: Expression; + computed: boolean; +} + +export interface AssignmentExpression extends BaseNode { + type: "AssignmentExpression"; + operator: string; + left: Pattern | Expression; + right: Expression; +} + +export interface BinaryExpression extends BaseNode { + type: "BinaryExpression"; + operator: string; + left: Expression; + right: Expression; +} + +export interface UnaryExpression extends BaseNode { + type: "UnaryExpression"; + operator: string; + argument: Expression; + prefix: boolean; +} + +export interface UpdateExpression extends BaseNode { + type: "UpdateExpression"; + operator: "++" | "--"; + argument: Expression; + prefix: boolean; +} + +export interface LogicalExpression extends BaseNode { + type: "LogicalExpression"; + operator: "||" | "&&" | "??"; + left: Expression; + right: Expression; +} + +export interface ConditionalExpression extends BaseNode { + type: "ConditionalExpression"; + test: Expression; + consequent: Expression; + alternate: Expression; +} + +export interface ArrayExpression extends BaseNode { + type: "ArrayExpression"; + elements: (Expression | null)[]; +} + +export interface ObjectExpression extends BaseNode { + type: "ObjectExpression"; + properties: Property[]; +} + +export interface Property extends BaseNode { + type: "Property"; + key: Expression; + value: Expression; + kind: "init" | "get" | "set"; + shorthand: boolean; + computed: boolean; +} + +export interface Literal extends BaseNode { + type: "Literal"; + value: string | number | boolean | null | RegExp; + raw: string; +} + +export interface ObjectPattern extends BaseNode { + type: "ObjectPattern"; + properties: Property[]; +} + +export interface ArrayPattern extends BaseNode { + type: "ArrayPattern"; + elements: (Pattern | null)[]; +} + +export interface RestElement extends BaseNode { + type: "RestElement"; + argument: Pattern; +} + +export interface AssignmentPattern extends BaseNode { + type: "AssignmentPattern"; + left: Pattern; + right: Expression; +}