From e13aa885b3b78832280349047ecab96c813c1e25 Mon Sep 17 00:00:00 2001 From: Daniel Brice Date: Mon, 4 Mar 2024 15:23:06 -0800 Subject: [PATCH] unreleased: * Reload config and restart extension when config changes. * Add `initTagsCommand` and `refreshTagsCommand` to `TagsConfig`. * Add _Restart Alloglot_ command. --- CHANGELOG.md | 6 +++ README.md | 21 ++++++-- package.json | 4 ++ src/config.ts | 81 +++++++++++++++++++++++++++-- src/extension.ts | 70 ++++++++----------------- src/tags.ts | 132 ++++++++++++++++++++++++++++++++--------------- 6 files changed, 216 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f86d1..a13273d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the "alloglot" extension will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). This project adhere's to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased] + +- Reload config and restart extension when config changes. +- Add `initTagsCommand` and `refreshTagsCommand` to `TagsConfig`. +- Add _Restart Alloglot_ command. + ## [2.3.0] - 2024-02-14 - If there the user has no user-level or workspace-level alloglot settings, settings will be read from a file `.vscode/alloglot.json` if one exists. diff --git a/README.md b/README.md index 4bff3a9..0cd6b99 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Most of the properties are optional, so you can make use of only the features th "apiSearchUrl": "https://hoogle.haskell.org/?hoogle=${query}", "tags": { "file": ".tags", + "initTagsCommand": "ghc-tags -c", + "refreshTagsCommand": "ghc-tags -c", "completionsProvider": true, "definitionsProvider": true, "importsProvider": { @@ -119,7 +121,7 @@ export type LanguageConfig = { /** * A formatter command. * Reads from STDIN and writes to STDOUT. - * `${file}` will be replaced with the path to the file. + * `${file}` will be replaced with the relative path to the file. */ formatCommand?: string @@ -147,17 +149,28 @@ export type TagsConfig = { file: string /** - * Use the contents of this tags file to suggest completions. + * A command to generate the tags file. + */ + initTagsCommand?: string + + /** + * A command to refresh the tags file when a file is saved. + * `${file}` will be replaced with the relative path to the file. + */ + refreshTagsCommand?: string + + /** + * Indicates that this tags file should be used to suggest completions. */ completionsProvider?: boolean /** - * Use the contents of this tags file to go to definitions. + * Indicates that this tags file should be used to go to definitions. */ definitionsProvider?: boolean /** - * Use the contents of this tags file to suggest imports. + * Indicates that this tags file should be used to suggest imports for symbols. */ importsProvider?: ImportsProviderConfig } diff --git a/package.json b/package.json index 78231f9..da4d96d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,10 @@ "main": "./dist/extension.js", "contributes": { "commands": [ + { + "command": "alloglot.command.restart", + "title": "Restart Alloglot" + }, { "command": "alloglot.command.apisearch", "title": "Go to API Search" diff --git a/src/config.ts b/src/config.ts index 378c35e..9d1ac31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,6 @@ +import * as vscode from 'vscode' +import { readFileSync } from 'fs' + /** * Extension configuration. */ @@ -26,7 +29,7 @@ export type LanguageConfig = { /** * A formatter command. * Reads from STDIN and writes to STDOUT. - * `${file}` will be replaced with the path to the file. + * `${file}` will be replaced with the relative path to the file. */ formatCommand?: string @@ -54,17 +57,28 @@ export type TagsConfig = { file: string /** - * Use the contents of this tags file to suggest completions. + * A command to generate the tags file. + */ + initTagsCommand?: string + + /** + * A command to refresh the tags file when a file is saved. + * `${file}` will be replaced with the relative path to the file. + */ + refreshTagsCommand?: string + + /** + * Indicates that this tags file should be used to suggest completions. */ completionsProvider?: boolean /** - * Use the contents of this tags file to go to definitions. + * Indicates that this tags file should be used to go to definitions. */ definitionsProvider?: boolean /** - * Use the contents of this tags file to suggest imports. + * Indicates that this tags file should be used to suggest imports for symbols. */ importsProvider?: ImportsProviderConfig } @@ -155,6 +169,62 @@ export type AnnotationsMapping = { referenceCode?: Array } +export namespace Config { + export function create(): Config { + return sanitizeConfig(readSettings() || readFallback() || empty) + } + + const empty: Config = { languages: [] } + + function readFallback(): Config | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders?.map(folder => folder.uri) + try { + if (workspaceFolders && workspaceFolders.length > 0) { + const fullPath = vscode.Uri.joinPath(workspaceFolders[0], alloglot.config.fallbackPath) + return JSON.parse(readFileSync(fullPath.path, 'utf-8')) + } + } catch (err) { + return undefined + } + } + + function readSettings(): Config | undefined { + const languages = vscode.workspace.getConfiguration(alloglot.config.root).get>(alloglot.config.languages) + return languages && { languages } + } + + function sanitizeConfig(config: Config): Config { + return { + languages: config.languages + .filter(lang => { + // make sure no fields are whitespace-only + // we mutate the original object because typescript doesn't have a `filterMap` function + + lang.languageId = lang.languageId.trim() + lang.serverCommand = lang.serverCommand?.trim() + lang.formatCommand = lang.formatCommand?.trim() + lang.apiSearchUrl = lang.apiSearchUrl?.trim() + + lang.annotations = lang.annotations?.filter(ann => { + ann.file = ann.file.trim() + return ann.file + }) + + if (lang.tags) { + lang.tags.file = lang.tags.file.trim() + lang.tags.initTagsCommand = lang.tags.initTagsCommand?.trim() + lang.tags.refreshTagsCommand = lang.tags.refreshTagsCommand?.trim() + if (!lang.tags?.importsProvider?.importLinePattern.trim()) lang.tags.importsProvider = undefined + if (!lang.tags?.importsProvider?.matchFromFilepath.trim()) lang.tags.importsProvider = undefined + if (!lang.tags.file) lang.tags = undefined + } + + return lang.languageId + }) + } + } +} + export namespace alloglot { export const root = 'alloglot' as const @@ -164,11 +234,14 @@ export namespace alloglot { export namespace commands { const root = `${alloglot.root}.command` as const + export const restart = `${root}.restart` as const export const apiSearch = `${root}.apisearch` as const export const suggestImports = `${root}.suggestimports` as const } export namespace config { + export const root = alloglot.root + export const fallbackPath = `.vscode/${root}.json` as const export const languages = 'languages' as const } } diff --git a/src/extension.ts b/src/extension.ts index e6d46cf..1bfbd84 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,43 +3,15 @@ import * as vscode from 'vscode' import { makeAnnotations } from './annotations' import { makeApiSearch } from './apisearch' import { makeClient } from './client' -import { Config, LanguageConfig, alloglot } from './config' +import { Config, alloglot } from './config' import { makeFormatter } from './formatter' import { makeTags } from './tags' -import { readFileSync, writeFile, writeFileSync } from 'fs' -export function activate(context: vscode.ExtensionContext): void { - const settingsSection = vscode.workspace.getConfiguration(alloglot.root) - const alloglotWorkspaceLanguages = getWorkspaceConfig('.vscode/alloglot.json'); - const alloglotVscodeLanguages = settingsSection.get>(alloglot.config.languages, []) - const alloglotLanguages = alloglotVscodeLanguages.length === 0 ? alloglotWorkspaceLanguages : alloglotVscodeLanguages; - - const config: Config = { - languages: alloglotLanguages - .filter(lang => { - // make sure no fields are whitespace-only - // we mutate the original object because typescript doesn't have a `filterMap` function - - lang.languageId = lang.languageId.trim() - lang.serverCommand = lang.serverCommand?.trim() - lang.formatCommand = lang.formatCommand?.trim() - lang.apiSearchUrl = lang.apiSearchUrl?.trim() - - lang.annotations = lang.annotations?.filter(ann => { - ann.file = ann.file.trim() - return ann.file - }) - - if (lang.tags) { - lang.tags.file = lang.tags.file.trim() - if (!lang.tags?.importsProvider?.importLinePattern.trim()) lang.tags.importsProvider = undefined - if (!lang.tags?.importsProvider?.matchFromFilepath.trim()) lang.tags.importsProvider = undefined - if (!lang.tags.file) lang.tags = undefined - } +let globalContext: vscode.ExtensionContext | undefined - return lang.languageId - }) - } +export function activate(context: vscode.ExtensionContext): void { + globalContext = context + const config = Config.create() context.subscriptions.push( // Make a single API search command because VSCode can't dynamically create commands. @@ -51,23 +23,25 @@ export function activate(context: vscode.ExtensionContext): void { // ...dynamically create LSP clients ...config.languages.map(makeClient), // ...and dynamically create completions, definitions, and code actions providers. - ...config.languages.map(makeTags) + ...config.languages.map(makeTags), + // restart extension when config changes + vscode.workspace.onDidChangeConfiguration(ev => { + if (ev.affectsConfiguration(alloglot.config.root)) restart(context) + }), + // user command to restart extension + vscode.commands.registerCommand(alloglot.commands.restart, () => restart(context)), ) } -function getWorkspaceConfig(workspaceConfigPath: string): LanguageConfig[] { - const workspaceFolders = vscode.workspace.workspaceFolders?.map(folder => folder.uri) - try { - if (workspaceFolders && workspaceFolders.length > 0) - { - const fullPath = vscode.Uri.joinPath(workspaceFolders[0], workspaceConfigPath); - return JSON.parse(readFileSync(fullPath.path,'utf-8')).languages; - } else { - return [] - } - } catch (err){ - return [] - } +export function deactivate() { + globalContext && disposeAll(globalContext) +} + +function disposeAll(context: vscode.ExtensionContext) { + context.subscriptions.forEach(sub => sub.dispose()) } -export function deactivate() { } +function restart(context: vscode.ExtensionContext) { + disposeAll(context) + activate(context) +} diff --git a/src/tags.ts b/src/tags.ts index ff63611..128f9f3 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -7,7 +7,7 @@ export function makeTags(config: LanguageConfig): vscode.Disposable { const { languageId, tags } = config if (!languageId || !tags) return vscode.Disposable.from() - const { completionsProvider, definitionsProvider, importsProvider } = tags + const { completionsProvider, definitionsProvider, importsProvider, initTagsCommand, refreshTagsCommand } = tags const basedir: vscode.Uri | undefined = vscode.workspace.workspaceFolders?.[0].uri const tagsUri: vscode.Uri | undefined = basedir && vscode.Uri.joinPath(basedir, tags.file) @@ -20,9 +20,9 @@ export function makeTags(config: LanguageConfig): vscode.Disposable { const output = vscode.window.createOutputChannel(clientId) output.appendLine(`${alloglot.root}: Starting tags for ${languageId}`) - const tagsSource = makeTagsSource(basedir, tagsUri, output) + const tagsSource = TagsSource.make({ languageId, basedir, tagsUri, output, initTagsCommand, refreshTagsCommand }) - const disposables: Array = [] + const disposables: Array = [tagsSource] if (completionsProvider) { output.appendLine('Registering completions provider...') @@ -101,7 +101,7 @@ export function makeTags(config: LanguageConfig): vscode.Disposable { return result.join() } - function renderImportLine(tag: Tag): { renderedImport?: string, renderedModuleName?: string } { + function renderImportLine(tag: TagsSource.Tag): { renderedImport?: string, renderedModuleName?: string } { output.appendLine(`Rendering import line for ${JSON.stringify(tag)}`) const fileMatcher = new RegExp(matchFromFilepath) @@ -143,7 +143,7 @@ export function makeTags(config: LanguageConfig): vscode.Disposable { return new vscode.Position(0, 0) } - function makeImportSuggestion(document: vscode.TextDocument, tag: Tag): ImportSuggestion | undefined { + function makeImportSuggestion(document: vscode.TextDocument, tag: TagsSource.Tag): ImportSuggestion | undefined { output.appendLine(`Making import suggestion for ${JSON.stringify(tag)}`) const { renderedImport, renderedModuleName } = renderImportLine(tag) if (!renderedImport || !renderedModuleName) return undefined @@ -200,21 +200,77 @@ type ImportSuggestion = { edit: vscode.WorkspaceEdit } -type Tag = { - symbol: string - file: string - lineNumber: number +type TagsSource = vscode.Disposable & { + findPrefix(prefix: string, limit?: number): Promise> + findExact(exact: string, limit?: number): Promise> } -type TagsSource = { - findPrefix(prefix: string, limit?: number): Promise> - findExact(exact: string, limit?: number): Promise> -} +namespace TagsSource { + export type Tag = { + symbol: string + file: string + lineNumber: number + } + + export type Config = { + languageId: string, + basedir: vscode.Uri, + tagsUri: vscode.Uri, + output: vscode.OutputChannel, + initTagsCommand?: string, + refreshTagsCommand?: string + } + + export function make(config: Config): TagsSource { + const { languageId, basedir, tagsUri, output, initTagsCommand, refreshTagsCommand } = config + output.appendLine(`Creating tags source for ${tagsUri.fsPath}`) + + if (initTagsCommand) { + output.appendLine('Initializing tags file...') + asyncRunProc(output, initTagsCommand, basedir, () => undefined) + output.appendLine('Tags file initialized.') + } + + const onSaveWatcher = (() => { + if (!refreshTagsCommand) return vscode.Disposable.from() + + const refreshTags = (doc: vscode.TextDocument) => { + if (doc.languageId === languageId) { + output.appendLine('Refreshing tags file...') + asyncRunProc(output, refreshTagsCommand.replace('${file}', doc.fileName), basedir, () => undefined) + output.appendLine('Tags file refreshed.') + } + } + + return vscode.workspace.onDidSaveTextDocument(refreshTags) + })() + + return { + findPrefix(prefix: string, limit: number = 100) { + if (!prefix) return Promise.resolve([]) + const escaped = prefix.replace(/(["\s'$`\\])/g, '\\$1') + return grep(config, output, new RegExp(`^${escaped}`), limit) + }, + findExact(exact: string, limit: number = 100) { + if (!exact) return Promise.resolve([]) + const escaped = exact.replace(/(["\s'$`\\])/g, '\\$1') + return grep(config, output, new RegExp(`^${escaped}\\t`), limit) + }, + dispose() { + onSaveWatcher.dispose() + } + } + } -function makeTagsSource(basedir: vscode.Uri, tagsUri: vscode.Uri, output: vscode.OutputChannel): TagsSource { - output.appendLine(`Creating tags source for ${tagsUri.fsPath}`) + function grep(config: Config, output: vscode.OutputChannel, regexp: RegExp, limit: number): Promise> { + const { tagsUri, basedir } = config + const command = `grep -P '${regexp.source}' ${tagsUri.fsPath} | head -n ${limit}` - function parseTag(line: string): Tag | undefined { + output.appendLine(`Searching for ${regexp} in ${tagsUri.fsPath}...`) + return asyncRunProc(output, command, basedir, stdout => filterMap(stdout.split('\n'), tag => parseTag(output, tag))) + } + + function parseTag(output: vscode.OutputChannel, line: string): Tag | undefined { output.appendLine(`Parsing tag line: ${line}`) const [symbol, file, rawLineNumber] = line.split('\t') let lineNumber = parseInt(rawLineNumber) @@ -224,42 +280,34 @@ function makeTagsSource(basedir: vscode.Uri, tagsUri: vscode.Uri, output: vscode return tag } - function grep(regexp: RegExp, limit: number): Promise> { - output.appendLine(`Searching for ${regexp} in ${tagsUri.fsPath}...`) - - const command = `grep -P '${regexp.source}' ${tagsUri.fsPath} | head -n ${limit}` - const cwd = basedir.fsPath - + function asyncRunProc(out: vscode.OutputChannel, cmd: string, dir: vscode.Uri, f: (stdout: string) => T): Promise { + const cwd = dir.fsPath return new Promise((resolve, reject) => { - const proc = exec(command, { cwd }, (error, stdout, stderr) => { + out.appendLine(`Running '${cmd}' in '${cwd}'...`) + + const proc = exec(cmd, { cwd }, (error, stdout, stderr) => { if (error) { - output.appendLine(`Error searching file:\t${error}`) + out.appendLine(`Error running '${cmd}':\n\t${error}`) reject(error) } - else if (!stdout) { - output.appendLine(`No search results.`) - resolve([]) - } - else { - stderr && output.appendLine(`Search logs:\n${stderr}`) - resolve(stdout.split('\n').map(parseTag).filter(x => x) as Array) - } + + stderr && out.appendLine(`Logs running '${cmd}':\n\t${stderr}`) + !stdout && out.appendLine(`No output running '${cmd}'.`) + + resolve(f(stdout)) }) proc.stdin?.end() + out.appendLine(`Ran '${cmd}'.`) }) } - return { - findPrefix(prefix: string, limit: number = 100) { - if (!prefix) return Promise.resolve([]) - const escaped = prefix.replace(/(["\s'$`\\])/g, '\\$1') - return grep(new RegExp(`^${escaped}`), limit) - }, - findExact(exact: string, limit: number = 100) { - if (!exact) return Promise.resolve([]) - const escaped = exact.replace(/(["\s'$`\\])/g, '\\$1') - return grep(new RegExp(`^${escaped}\\t`), limit) + function filterMap(xs: Array, f: (x: T) => U | undefined): Array { + const result: Array = [] + for (const x of xs) { + const y = f(x) + if (y !== undefined) result.push(y) } + return result } }