diff --git a/LICENSE b/LICENSE index 83fc8f8..441e021 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2021 Sam Gwilym +Copyright (c) 2015-present, Brian Woodward Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/deno.novaextension/extension.json b/deno.novaextension/extension.json index 594339e..f299a62 100644 --- a/deno.novaextension/extension.json +++ b/deno.novaextension/extension.json @@ -142,12 +142,12 @@ { "id": "co.gwil.deno.sidebars.symbols", "name": "Deno Symbols", - "placeholderText": "Use the Find Symbol command to find symbols with Deno.", "largeImage": "sidebar_large", "smallImage": "sidebar_small", "sections": [ { "id": "co.gwil.deno.sidebars.symbols.sections.1", + "placeholderText": "Use the Find Symbol command to find symbols with Deno.", "name": "Symbols", "headerCommands": [ { @@ -157,6 +157,43 @@ ] } ] + }, + { + "id": "co.gwil.deno.sidebars.tests", + "name": "Deno Tests", + "largeImage": "test_sidebar_large", + "smallImage": "test_sidebar_small", + "sections": [ + { + "id": "co.gwil.deno.sidebars.tests.sections.1", + "placeholderText": "No tests were found.", + "name": "Tests", + "headerCommands": [ + { + "title": "Learn more", + "command": "co.gwil.deno.sidebars.tests.commands.learn" + }, + { + "title": "Refresh", + "tooltip": "Check for the addition or removal of test files", + "command": "co.gwil.deno.sidebars.tests.commands.refresh", + "image": "__builtin.refresh" + }, + { + "title": "Run All", + "command": "co.gwil.deno.sidebars.tests.commands.runAll", + "image": "run" + } + ], + "contextCommands": [ + { + "title": "Run", + "command": "co.gwil.deno.sidebars.tests.commands.run", + "when": "viewItem == 'file'" + } + ] + } + ] } ], diff --git a/src/client_disposable.ts b/src/client_disposable.ts index 0bd28db..9cf7535 100644 --- a/src/client_disposable.ts +++ b/src/client_disposable.ts @@ -10,8 +10,7 @@ import { import registerFormatDocument from "./commands/format_document.ts"; import registerCache from "./commands/cache.ts"; import registerRenameSymbol from "./commands/rename_symbol.ts"; -import registerPaletteFindSymbol from "./commands/palette_find_symbol.ts"; -import registerSymbolSidebarFindSymbol from "./commands/sidebar_find_symbol.ts"; +import registerFindSymbol from "./find_symbol/register.ts"; import syntaxes from "./syntaxes.ts"; const FORMAT_ON_SAVE_CONFIG_KEY = "co.gwil.deno.config.formatOnSave"; @@ -140,11 +139,7 @@ export async function makeClientDisposable( clientDisposable.add(registerFormatDocument(client)); clientDisposable.add(registerCache(client)); clientDisposable.add(registerRenameSymbol(client)); - - // palette Find Symbol command - clientDisposable.add(registerPaletteFindSymbol()); - // sidebar Find Symbol command - clientDisposable.add(registerSymbolSidebarFindSymbol(client)); + clientDisposable.add(registerFindSymbol(client)); nova.workspace.onDidAddTextEditor((editor) => { const editorDisposable = new CompositeDisposable(); diff --git a/src/commands/palette_find_symbol.ts b/src/commands/palette_find_symbol.ts deleted file mode 100644 index c23803c..0000000 --- a/src/commands/palette_find_symbol.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NotificationRequest, nova, wrapCommand } from "../nova_utils.ts"; - -export default function registerFindSymbol() { - return nova.commands.register( - "co.gwil.deno.commands.find", - wrapCommand(maybeFindSymbol), - ); - - async function maybeFindSymbol() { - try { - await nova.commands.invoke("co.gwil.deno.sidebars.symbols.commands.find"); - } catch (err) { - // I don't think this ever happens. - console.error(err); - } - - const sidebarReminderNotificationRequest = new NotificationRequest( - "co.gwil.deno.notifications.checkTheSidebar", - ); - sidebarReminderNotificationRequest.title = - "Symbols are shown in the Deno Symbols sidebar."; - sidebarReminderNotificationRequest.body = - "To see your search's results, check the Deno Symbols sidebar."; - nova.notifications.add(sidebarReminderNotificationRequest); - } -} diff --git a/src/commands/symbols.sidebar.ts b/src/commands/symbols.sidebar.ts new file mode 100644 index 0000000..fd1b074 --- /dev/null +++ b/src/commands/symbols.sidebar.ts @@ -0,0 +1,81 @@ +import { + LanguageClient, + NotificationRequest, + nova, + wrapCommand, +} from "../nova_utils.ts"; +import SymbolDataProvider from "../find_symbol/SymbolDataProvider.ts"; +import { lsp } from "../../deps.ts"; + +export function registerPaletteFindSymbol() { + return nova.commands.register( + "co.gwil.deno.commands.find", + wrapCommand(maybeFindSymbol), + ); + + async function maybeFindSymbol() { + try { + await nova.commands.invoke("co.gwil.deno.sidebars.symbols.commands.find"); + } catch (err) { + // I don't think this ever happens. + console.error(err); + } + + const sidebarReminderNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.checkTheSidebar", + ); + sidebarReminderNotificationRequest.title = + "Symbols are shown in the Deno Symbols sidebar."; + sidebarReminderNotificationRequest.body = + "To see your search's results, check the Deno Symbols sidebar."; + nova.notifications.add(sidebarReminderNotificationRequest); + } +} + +export function registerSidebarFindSymbol( + client: LanguageClient, + symbolDataProvider: SymbolDataProvider, +) { + return nova.commands.register( + "co.gwil.deno.sidebars.symbols.commands.find", + wrapCommand(findSymbol), + ); + + async function findSymbol() { + if ( + // @ts-expect-error: The Nova types are outdated. + !(nova.workspace.context as Configuration).get("shouldDisplayFeatures") + ) { + const failureNotificationReq = new NotificationRequest( + "co.gwil.deno.notifications.findSymbolUnavailable", + ); + failureNotificationReq.title = "Find Symbol is unavailable."; + failureNotificationReq.body = + "Open a TypeScript, JavaScript, JSX or TSX file."; + nova.notifications.add(failureNotificationReq); + return; + } + + const query = await new Promise((resolve) => + nova.workspace.showInputPalette( + "Type the name of a variable, class or function.", + {}, + resolve, + ) + ) as string | null | undefined; + + // This happens if the user exits the palette, for example, by pressing Escape. + if (!query) return; + + symbolDataProvider.displaySymbols(query, getSymbols); + + async function getSymbols(query: string) { + const params = { query }; + const response = await client.sendRequest( + "workspace/symbol", + params, + ) as lsp.SymbolInformation[] | null; + return response; + } + } +} diff --git a/src/commands/tests.sidebar.ts b/src/commands/tests.sidebar.ts new file mode 100644 index 0000000..7699019 --- /dev/null +++ b/src/commands/tests.sidebar.ts @@ -0,0 +1,176 @@ +import { + FileTextMode, + NotificationRequest, + nova, + Process, + wrapCommand, +} from "../nova_utils.ts"; +import TestsDataProvider, { + TestFile, + UnexpectedLogError, +} from "../tests/TestsDataProvider.ts"; + +export function registerLearnMore() { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.learn", + wrapCommand(learnMore), + ); + + function learnMore() { + const options = { + args: ["https://deno.land/manual/testing#running-tests"], + }; + const process = new Process("/usr/bin/open", options); + process.start(); + } +} + +export function registerRefresh(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.refresh", + wrapCommand(refresh), + ); + + function refresh() { + if (nova.workspace.path) { + testsDataProvider.updateFiles(nova.workspace.path); + testsDataProvider.treeView.reload(); + } else { + const informativeNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.missingWorkspacePathForTests", + ); + informativeNotificationRequest.title = + "The tests sidebar is unavailable."; + informativeNotificationRequest.body = + "Open a project in which to look for tests."; + nova.notifications.add(informativeNotificationRequest); + } + } +} + +export function registerRunAll(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.runAll", + wrapCommand(runAll), + ); + + async function runAll() { + const hiddenWorkspaceDataPath = nova.path.join( + nova.extension.workspaceStoragePath, + "state.json", + ); + + function readState(path: string): Record { + try { + const file = nova.fs.open(path) as FileTextMode; + const content = file.readlines().join("\n"); + file.close(); + + return JSON.parse(content); + } catch { + return {}; + } + } + function writeState(path: string, content: Record) { + let file; + try { + file = nova.fs.open(path, "w") as FileTextMode; + } catch { + try { + nova.fs.mkdir(nova.extension.workspaceStoragePath); + } catch (e) { + throw new Error( + "Could not access the extension's workspace storage path.", + { cause: e }, + ); + } + file = nova.fs.open(path, "w") as FileTextMode; + } + file.write(JSON.stringify(content)); + } + + const state = readState(hiddenWorkspaceDataPath); + if (!state.warningWasShown) { + const permissionsNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.findSymbolUnavailable", + ); + permissionsNotificationRequest.title = + "Tests are awarded all permissions."; + permissionsNotificationRequest.body = + "Test files may access environment variables, load dynamic libraries, measure time in high resolution, utilize the network, read files and write files. This is not configurable at the moment."; + permissionsNotificationRequest.actions = ["Cancel", "Allow"]; + + const response = await nova.notifications.add( + permissionsNotificationRequest, + ); + if (response.actionIdx == 0) { + return; + } + + state.warningWasShown = true; + writeState(hiddenWorkspaceDataPath, state); + } + + const timeoutID = setTimeout(() => { + const stillRunningNotificationRequest = new NotificationRequest( + "co.gwil.deno.notifications.runningTests", + ); + stillRunningNotificationRequest.title = "The tests are still being run."; + stillRunningNotificationRequest.body = + "It's just taking a while. Please wait."; + nova.notifications.add(stillRunningNotificationRequest); + }, 3 * 1000); + + try { + await testsDataProvider.runTests(); + testsDataProvider.treeView.reload(); + } catch (e) { + if (e instanceof UnexpectedLogError) { + const dissatisfactoryOutcomeNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.fragileTestsSidebar", + ); + dissatisfactoryOutcomeNotificationRequest.title = + "The tests' outcomes are unknown."; + dissatisfactoryOutcomeNotificationRequest.body = + "This feature is fragile, and relies on the extension's compatibility with your particular Deno version. If you're using an outdated version of Deno, or an outdated version of the extension, try updating."; + dissatisfactoryOutcomeNotificationRequest.actions = ["OK"]; + nova.notifications.add(dissatisfactoryOutcomeNotificationRequest); + } else { + // shown as a Nova message regardless; doesn't produce a crash + throw e; + } + } finally { + clearTimeout(timeoutID); + } + } +} + +export function registerRun(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.run", + wrapCommand(run), + ); + + async function run() { + // This command is only available when `TestFile`s, exclusively, are selected. + const selected = testsDataProvider.treeView.selection as TestFile[]; + await testsDataProvider.runTests(selected.map((file) => file.path)); + testsDataProvider.treeView.reload(); + } +} + +// This wrapper is used by the sidebar to enable test files to be opened by double-clicking. +export function registerOpen(testsDataProvider: TestsDataProvider) { + return nova.commands.register( + "co.gwil.deno.sidebars.tests.commands.open", + wrapCommand(open), + ); + + function open() { + const selected = testsDataProvider.treeView.selection[0]; + if (selected?.path) { + nova.workspace.openFile(selected.path); + } + } +} diff --git a/src/commands/sidebar_find_symbol.ts b/src/find_symbol/SymbolDataProvider.ts similarity index 57% rename from src/commands/sidebar_find_symbol.ts rename to src/find_symbol/SymbolDataProvider.ts index ba39d32..4ca0b9b 100644 --- a/src/commands/sidebar_find_symbol.ts +++ b/src/find_symbol/SymbolDataProvider.ts @@ -1,44 +1,40 @@ import { - Configuration, Disposable, - LanguageClient, lspRangeToRange, - NotificationRequest, nova, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, - wrapCommand, } from "../nova_utils.ts"; import { lsp } from "../../deps.ts"; -let symbolDataProvider: SymbolDataProvider | null = null; +import { Element, Header } from "../sidebars.ts"; -interface ElementConversionOptions { - shouldDisambiguate?: boolean; +function getFilename(uri: string) { + return decodeURIComponent(uri).split("/").pop()!; } -type Element = File | Header | Symbol; -class File { +class File implements Element { uri: string; extension: string; children: Symbol[]; - constructor(uri: string, children: Symbol[]) { + shouldDisambiguate: boolean; + + constructor(uri: string, children: Symbol[], shouldDisambiguate: boolean) { this.uri = uri; this.extension = uri.split(".").pop()!; this.children = children; + this.shouldDisambiguate = shouldDisambiguate; } get filename(): string { - return decodeURIComponent(this.uri).split("/").pop()!; + return getFilename(this.uri); } - toTreeItem( - { shouldDisambiguate }: ElementConversionOptions = {}, - ) { + toTreeItem() { const path = decodeURIComponent(this.uri.slice(this.uri.indexOf(":") + 1)); const relativePath: string = nova.workspace.relativizePath(path); const item = new TreeItem( - shouldDisambiguate ? relativePath : this.filename, + this.shouldDisambiguate ? relativePath : this.filename, ); item.image = "__filetype." + this.extension; @@ -46,28 +42,18 @@ class File { return item; } } -class Header { - content: string; - children: []; - constructor(content: string) { - this.content = content; - this.children = []; - } - - toTreeItem() { - const item = new TreeItem(this.content); - return item; - } -} -class Symbol { +class Symbol implements Element { name: string; type: string; - location: lsp.Location; children: []; - constructor(lspSymbol: lsp.SymbolInformation) { + private getLocation: () => lsp.Location; + constructor( + lspSymbol: lsp.SymbolInformation, + getLocation: () => lsp.Location, + ) { this.name = lspSymbol.name; this.type = this.getType(lspSymbol); - this.location = lspSymbol.location; + this.getLocation = getLocation; this.children = []; } @@ -183,8 +169,8 @@ class Symbol { } async show() { - const uri = this.location.uri; - const lspRange = this.location.range; + const uri = this.getLocation().uri; + const lspRange = this.getLocation().range; const editor = await nova.workspace.openFile(uri); if (!editor) { @@ -196,13 +182,17 @@ class Symbol { editor.scrollToPosition(range.start); } } -class SymbolDataProvider implements TreeDataProvider { - private treeView: TreeView; - private symbols: Map; +export default class SymbolDataProvider implements TreeDataProvider { + treeView: TreeView; + + // The locations need to be stored separately to enable the sidebar to reload infrequently. + private locations: Map; private files: File[]; + + private previousNames: string[] | undefined; + private currentQuery: string | null; private headerMessage: string | null; - private ambiguousFilenames: string[]; constructor() { this.treeView = new TreeView("co.gwil.deno.sidebars.symbols.sections.1", { @@ -210,9 +200,8 @@ class SymbolDataProvider implements TreeDataProvider { }); this.treeView.onDidChangeSelection(this.onDidChangeSelection); - this.symbols = new Map(); + this.locations = new Map(); this.files = []; - this.ambiguousFilenames = []; this.headerMessage = null; this.currentQuery = null; @@ -235,52 +224,90 @@ class SymbolDataProvider implements TreeDataProvider { return this.treeView.reload(); } - private setSymbols(lspSymbols: lsp.SymbolInformation[]) { - const symbols = lspSymbols.filter((lspSymbol) => - // keep only symbols from real files - lspSymbol.location.uri.startsWith("file://") - ).map( - // turn into `Symbol`s - (lspSymbol) => new Symbol(lspSymbol), - ); + private setSymbols( + lspSymbols: lsp.SymbolInformation[], + didQueryChange: boolean, + ) { + this.locations.clear(); + + const symbolMap = new Map(); + const names: string[] = []; + const oldNames = this.previousNames ?? []; + + let index = 0; + for (const lspSymbol of lspSymbols) { + const { uri } = lspSymbol.location; + if (uri.startsWith("file://")) { + this.locations.set(index, lspSymbol.location); + + // We need to make a copy because `index` otherwise evaluates (usually, if not always) to the value to which it is bound at the end of the loop, rather than to the value to which it is bound at the time at which this code runs. I was very amazed by this behavior. Let me know if you, the reader, expected it. + const index1 = index; + const symbol = new Symbol(lspSymbol, () => this.locations.get(index1)!); + symbolMap.set(uri, [ + ...(symbolMap.get(uri) ?? []), + symbol, + ]); + names.push(symbol.name); + + index++; + } + } - this.symbols.clear(); - for (const symbol of symbols) { - const { uri } = symbol.location; - this.symbols.set( - uri, - [...(this.symbols.get(uri) ?? []), symbol], + const seenFilenames: string[] = []; + const ambiguousFilenames: string[] = []; + for (const [uri] of symbolMap) { + const filename = getFilename(uri); + if (seenFilenames.includes(filename)) { + ambiguousFilenames.push(filename); + } + seenFilenames.push(filename); + } + + const files: File[] = []; + for (const [uri, symbols] of symbolMap) { + const filename = getFilename(uri); + files.push( + new File(uri, symbols, ambiguousFilenames.includes(filename)), ); } - this.files = Array.from(this.symbols.keys()).map((uri) => - new File(uri, this.symbols.get(uri)!) - ); + this.files = files; + if (this.files.length) { + this.headerMessage = `Results for '${this.currentQuery}':`; + } else { + this.headerMessage = `No results found for '${this.currentQuery}'.`; + } - function findDuplicates(array: Type[]): Type[] { - const seenItems: Type[] = []; - const duplicates: Type[] = []; + function zip(...arrays: (Type[])[]): (Type[])[] { + const lengths = arrays.map((array) => array.length); + const largestArray = arrays[lengths.indexOf(Math.max(...lengths))]; + return largestArray.map((_item, index) => + arrays.map((item) => item[index]) + ); + } - for (const item of array) { - if (seenItems.includes(item)) { - duplicates.push(item); - } - seenItems.push(item); + // We need to reload if the query changes in order to keep the `headerMessage` accurate. + let shouldReload = didQueryChange; + for (const [newName, oldName] of zip(names, oldNames)) { + if (shouldReload) { + break; + } + if (newName != oldName) { + shouldReload = true; } - - return duplicates; } - this.ambiguousFilenames = findDuplicates( - this.files.map((file) => file.filename), - ); - return this.symbols; + if (shouldReload) { + this.reload(); + } + this.previousNames = names; } displaySymbols( query: string, getSymbols: (query: string) => Promise, ) { + let didQueryChange = true; this.currentQuery = query; /** @@ -300,14 +327,8 @@ class SymbolDataProvider implements TreeDataProvider { } const symbols = await getSymbols(query) ?? []; - const displayedSymbols = this.setSymbols(symbols); - - if (displayedSymbols.size) { - this.headerMessage = `Results for '${query}':`; - } else { - this.headerMessage = `No results found for '${query}'.`; - } - this.reload(); + this.setSymbols(symbols, didQueryChange); + didQueryChange = false; }; updateSymbols(); @@ -337,59 +358,6 @@ class SymbolDataProvider implements TreeDataProvider { } getTreeItem(element: Element) { - const options: ElementConversionOptions = { - shouldDisambiguate: element instanceof File && - this.ambiguousFilenames.includes(element.filename), - }; - return element.toTreeItem(options); - } -} - -export default function registerFindSymbol(client: LanguageClient) { - return nova.commands.register( - "co.gwil.deno.sidebars.symbols.commands.find", - wrapCommand(findSymbol), - ); - - async function findSymbol() { - if (!symbolDataProvider) { - symbolDataProvider = new SymbolDataProvider(); - } - - if ( - // @ts-expect-error: The Nova types are outdated. - !(nova.workspace.context as Configuration).get("shouldDisplayFeatures") - ) { - const failureNotificationReq = new NotificationRequest( - "co.gwil.deno.notifications.findSymbolUnavailable", - ); - failureNotificationReq.title = "Find Symbol is unavailable."; - failureNotificationReq.body = - "Open a TypeScript, JavaScript, JSX or TSX file."; - nova.notifications.add(failureNotificationReq); - return; - } - - const query = await new Promise((resolve) => - nova.workspace.showInputPalette( - "Type the name of a variable, class or function.", - {}, - resolve, - ) - ) as string | null | undefined; - - // This happens if the user exits the palette, for example, by pressing Escape. - if (!query) return; - - symbolDataProvider.displaySymbols(query, getSymbols); - - async function getSymbols(query: string) { - const params = { query }; - const response = await client.sendRequest( - "workspace/symbol", - params, - ) as lsp.SymbolInformation[] | null; - return response; - } + return element.toTreeItem(); } } diff --git a/src/find_symbol/register.ts b/src/find_symbol/register.ts new file mode 100644 index 0000000..9105e97 --- /dev/null +++ b/src/find_symbol/register.ts @@ -0,0 +1,18 @@ +import { CompositeDisposable, LanguageClient } from "../nova_utils.ts"; +import { + registerPaletteFindSymbol, + registerSidebarFindSymbol, +} from "../commands/symbols.sidebar.ts"; +import SymbolDataProvider from "./SymbolDataProvider.ts"; + +export default function registerSymbolsSidebar(client: LanguageClient) { + const symbolsDisposable = new CompositeDisposable(); + + const dataProvider = new SymbolDataProvider(); + symbolsDisposable.add(dataProvider.treeView); + + symbolsDisposable.add(registerSidebarFindSymbol(client, dataProvider)); + symbolsDisposable.add(registerPaletteFindSymbol()); + + return symbolsDisposable; +} diff --git a/src/nova_deno.ts b/src/nova_deno.ts index 71100fe..3539292 100644 --- a/src/nova_deno.ts +++ b/src/nova_deno.ts @@ -15,6 +15,7 @@ import { CanNotEnsureError, makeClientDisposable, } from "./client_disposable.ts"; +import { registerTestsSidebar } from "./tests/register.ts"; const compositeDisposable = new CompositeDisposable(); const taskDisposable = new CompositeDisposable(); @@ -53,6 +54,8 @@ export async function activate() { compositeDisposable.add(registerEditorWatcher()); + compositeDisposable.add(registerTestsSidebar()); + const configFileWatchingDisposables = watchConfigFiles( nova.workspace.path, configFilenames, diff --git a/src/sidebars.ts b/src/sidebars.ts new file mode 100644 index 0000000..9153dbd --- /dev/null +++ b/src/sidebars.ts @@ -0,0 +1,22 @@ +import { TreeItem } from "./nova_utils.ts"; + +export interface Element { + toTreeItem: () => TreeItem; + children: Element[]; + path?: string; + uri?: string; + shouldDisambiguate?: boolean; +} + +export class Header implements Element { + content: string; + children: []; + constructor(content: string) { + this.content = content; + this.children = []; + } + + toTreeItem() { + return new TreeItem(this.content); + } +} diff --git a/src/tests/TestsDataProvider.ts b/src/tests/TestsDataProvider.ts new file mode 100644 index 0000000..6b29688 --- /dev/null +++ b/src/tests/TestsDataProvider.ts @@ -0,0 +1,308 @@ +import { + Color, + ColorComponents, + ColorFormat, // It is, actually, read. I think this message is due to a @ts-expect-error. + getOverridableBoolean, + NotificationRequest, + nova, + Process, + TreeDataProvider, + TreeItem, + TreeItemCollapsibleState, + TreeView, +} from "../nova_utils.ts"; +import { Element, Header } from "../sidebars.ts"; + +class Test implements Element { + name: string; + passed: boolean; + children: []; + constructor(name: string, passed: boolean) { + this.name = name; + this.passed = passed; + this.children = []; + } + + toTreeItem() { + const item = new TreeItem(this.name); + // TODO item.identifier = + // @ts-expect-error: ColorFormat is a value —not a type. TypeScript is complaining otherwise. + const format = ColorFormat.rgb; + + const failedColorComponents: ColorComponents = [ + 0.88671875, + 0.23828125, + 0.23828125, + 1, + ]; + const passedColorComponents: ColorComponents = [ + 0.30859375, + 0.91796875, + 0.20312500, + 1, + ]; + + item.color = new Color( + format, + this.passed ? passedColorComponents : failedColorComponents, + ); + item.descriptiveText = this.passed ? "Passed" : "Failed"; + item.contextValue = "test"; + + return item; + } +} +export class UnexpectedLogError extends Error {} +export class TestFile implements Element { + path: string; + children: Element[]; + shouldDisambiguate: boolean; + + constructor(path: string, children: Element[], shouldDisambiguate: boolean) { + this.path = path; + this.children = children; + this.shouldDisambiguate = shouldDisambiguate; + } + + get filename(): string { + return nova.path.basename(this.path); + } + + get extension(): string { + return nova.path.extname(this.path); + } + + toTreeItem() { + const relativePath: string = nova.workspace.relativizePath(this.path); + const item = new TreeItem( + this.shouldDisambiguate ? relativePath : this.filename, + ); + + item.image = "__filetype" + this.extension; + item.collapsibleState = this.children.length + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.None; + item.contextValue = "file"; + item.identifier = this.path; + item.command = "co.gwil.deno.sidebars.tests.commands.open"; + + return item; + } +} + +export default class TestsDataProvider implements TreeDataProvider { + // See https://deno.land/manual/testing#running-tests. + static FILE_REGEXP = /^.*[_.]?test\.(ts|tsx|mts|js|mjs|jsx|cjs|cts)$/; + treeView: TreeView; + files: TestFile[]; + + constructor() { + this.treeView = new TreeView("co.gwil.deno.sidebars.tests.sections.1", { + dataProvider: this, + }); + this.files = []; + + if (nova.workspace.path) { + this.updateFiles(nova.workspace.path); + } + } + + static findTests(directoryPath: string): string[] { + function isAccessibleDirectory(path: string) { + try { + nova.fs.listdir(path); + } catch { + return false; + } + return true; + } + + const tests: string[] = []; + for (const name of nova.fs.listdir(directoryPath)) { + const path = nova.path.join(directoryPath, name); + if (isAccessibleDirectory(path)) { + let internalTests = null; + try { + internalTests = TestsDataProvider.findTests(path); + } catch { + continue; + } + tests.push(...internalTests); + } else if (name.match(TestsDataProvider.FILE_REGEXP)) { + tests.push(path); + } + } + + return tests; + } + + updateFiles(directoryPath: string) { + const newPaths = TestsDataProvider.findTests(directoryPath); + const existingPaths = this.files.map((file) => file.path); + + const toRemove: number[] = []; + for (let i = 0; i < existingPaths.length; i++) { + const path = existingPaths[i]; + if (!newPaths.includes(path)) { + toRemove.push(i); + } + } + this.files = this.files.filter((_value, index) => + !toRemove.includes(index) + ); + + for (const path of newPaths) { + if (!this.files.find((file) => file.path == path)) { + this.files.push( + new TestFile( + path, + [], + false, // TODO + ), + ); + } + } + + // The above process is carried out instead of replacing the `this.files` property. I prefer this because it does not remove test results from the sidebar. + } + + runTests(tests?: string[]) { + if (!nova.workspace.path) { + throw new Error("This function requires a workspace path."); + } + + const paths = tests ?? this.files.map((file) => file.path); + const args = ["test", "-A"]; + + if (getOverridableBoolean("co.gwil.deno.config.enableUnstable")) { + args.push("--unstable"); + } + const potentialImportMapLocation = nova.workspace.config.get( + "co.gwil.deno.config.import-map", + ); + if (potentialImportMapLocation) { + args.push("--import-map=" + potentialImportMapLocation); + } + + const options = { + args: ["deno", ...args, ...paths], + cwd: nova.workspace.path, + }; + const denoProcess = new Process("/usr/bin/env", options); + + // This must be very fragile! + const INTRODUCTION_REGEXP = /running (\d+) tests? from ([^]+)/; + const TEST_REGEXP = /([^]+) \.\.\. (FAILED|ok) \(\d+[A-Za-z]+\)$/; + const CONTROL_REGEXP = + // deno-lint-ignore no-control-regex + /[\u001b\u009b][[\]#;?()]*(?:(?:(?:[^\W_]*;?[^\W_]*)\u0007)|(?:(?:[0-9]{1,4}(;[0-9]{0,4})*)?[~0-9=<>cf-nqrtyA-PRZ]))/g; + + const output: TestFile[] = []; + + let loggingError: UnexpectedLogError | null = null; + denoProcess.onStderr((line) => { + // remove newline + line = line.slice(0, -1); + console.log(line); + }); + denoProcess.onStdout((line) => { + line = line.slice(0, -1); + + // remove control (?) characters that make output colorful + line = line.replace( + CONTROL_REGEXP, + "", + ); + const introduction = line.match(INTRODUCTION_REGEXP); + const test = line.match(TEST_REGEXP); + + if (introduction) { + const [, count, relativePath] = introduction; + output.push( + new TestFile( + nova.path.normalize( + nova.path.join(nova.workspace.path!, relativePath), + ), + count == "0" ? [new Header("No tests")] : [], + false, // TODO + ), + ); + } else if (test) { + const [, name, message] = test; + const passed = message == "ok"; + + const currentFile = output.at(-1); + if (!currentFile) { + if (!loggingError) { + loggingError = new UnexpectedLogError( + "Unexpected logging. Oops.", + ); + } + return; + } + currentFile.children.push(new Test(name, passed)); + } + }); + + const onExit = new Promise((resolve, reject) => { + denoProcess.onDidExit(() => { + if (loggingError) { + reject(loggingError); + } else { + for (const file of output) { + const analogousIndex = this.files.findIndex( + (oldFile) => oldFile.path == file.path, + ); + if (analogousIndex != -1) { + this.files[analogousIndex] = file; + } else { + this.files.push(file); + } + } + + const newPaths = output.map((file) => file.path); + const missingFiles = this.files.filter( + (file) => + paths.includes(file.path) && !newPaths.includes(file.path), + ); + for ( + const file of missingFiles + ) { + file.children = [new Header("Failed to run")]; + } + + if (missingFiles.length) { + const configurationErrorNotificationRequest = + new NotificationRequest( + "co.gwil.deno.notifications.unexpectedEmptiness", + ); + configurationErrorNotificationRequest.title = "Check the console."; + configurationErrorNotificationRequest.body = + "Deno may be failing to run some tests. Check the extension console for logging."; + nova.notifications.add(configurationErrorNotificationRequest); + } + + resolve(output); + } + }); + }); + + denoProcess.start(); + return onExit; + } + + getChildren(element: Element | null): Element[] { + if (element == null) { + if (nova.workspace.path) { + return this.files; + } else { + return []; + } + } + return element.children; + } + + getTreeItem(element: Element): TreeItem { + return element.toTreeItem(); + } +} diff --git a/src/tests/register.ts b/src/tests/register.ts new file mode 100644 index 0000000..1a21f9f --- /dev/null +++ b/src/tests/register.ts @@ -0,0 +1,37 @@ +import { CompositeDisposable, nova } from "../nova_utils.ts"; +import { + registerLearnMore, + registerOpen, + registerRefresh, + registerRun, + registerRunAll, +} from "../commands/tests.sidebar.ts"; +import TestsDataProvider from "./TestsDataProvider.ts"; + +export function registerTestsSidebar() { + const testsDisposable = new CompositeDisposable(); + + const dataProvider = new TestsDataProvider(); + testsDisposable.add(dataProvider.treeView); + + // It seems that Nova won't accept a more narrow glob, like the one in the Deno documentation. + // https://deno.land/manual/testing#running-tests + console.log(4); + if (nova.workspace.path) { + const GLOB = "*test.*"; + testsDisposable.add( + nova.fs.watch(GLOB, () => { + dataProvider.updateFiles(nova.workspace.path!); + dataProvider.treeView.reload(); + }), + ); + } + + testsDisposable.add(registerLearnMore()); + testsDisposable.add(registerRefresh(dataProvider)); + testsDisposable.add(registerRun(dataProvider)); + testsDisposable.add(registerRunAll(dataProvider)); + testsDisposable.add(registerOpen(dataProvider)); + + return testsDisposable; +}