From 9c82892bae37469deac1b6a0feebf633bcc74717 Mon Sep 17 00:00:00 2001 From: CismonX Date: Mon, 19 Apr 2021 20:43:20 +0800 Subject: [PATCH] Refactor code. --- src/context_mapping.ts | 97 ++++++++++++++-------- src/contexts/document.ts | 9 +- src/contexts/document_symbol.ts | 20 ++--- src/contexts/folding_range.ts | 121 ++++++++++++++------------- src/contexts/preview.ts | 138 +++++++++++++------------------ src/diagnosis.ts | 29 +++---- src/extension.ts | 27 +----- src/global_context.ts | 72 ++++++++++++++++ src/indicator.ts | 73 ++++++++-------- src/logger.ts | 25 ++---- src/options.ts | 72 ++++++---------- src/providers/code_lens.ts | 12 +-- src/providers/completion_item.ts | 31 +++---- src/providers/document_symbol.ts | 6 +- src/providers/folding_range.ts | 6 +- src/utils/converter.ts | 40 ++++----- src/utils/dom.ts | 28 +++---- src/utils/misc.ts | 12 +-- src/utils/types.ts | 16 ++-- 19 files changed, 430 insertions(+), 404 deletions(-) create mode 100644 src/global_context.ts diff --git a/src/context_mapping.ts b/src/context_mapping.ts index 347a6c7..17ad7ce 100644 --- a/src/context_mapping.ts +++ b/src/context_mapping.ts @@ -21,54 +21,85 @@ import * as vscode from 'vscode'; import DocumentContext from './contexts/document'; +import GlobalContext from './global_context'; +import { prompt } from './utils/misc'; /** - * Manage mappings between Texinfo documents and corresponding contexts. + * Manage mappings between Texinfo documents and corresponding document-specific contexts. */ export default class ContextMapping implements vscode.Disposable { - private static singleton?: ContextMapping; - - static get instance() { - return ContextMapping.singleton ??= new ContextMapping(); - } - - static getDocumentContext(document: vscode.TextDocument) { - let documentContext = ContextMapping.instance.value.get(document); + getDocumentContext(document: vscode.TextDocument) { + let documentContext = this.map.get(document); if (documentContext === undefined) { - ContextMapping.instance.value.set(document, documentContext = new DocumentContext(document)); + documentContext = new DocumentContext(this.globalContext, document); + this.map.set(document, documentContext); } return documentContext; } - static onDocumentUpdate(event: vscode.TextDocumentChangeEvent) { - const documentContext = ContextMapping.getDocumentContextIfExist(event.document); + dispose() { + this.map.forEach(documentContext => documentContext.getPreview()?.close()); + } + + constructor(private readonly globalContext: GlobalContext) { + globalContext.subscribe( + vscode.commands.registerTextEditorCommand('texinfo.preview.show', this.showPreview.bind(this)), + vscode.commands.registerCommand('texinfo.preview.goto', this.gotoPreview.bind(this)), + vscode.workspace.onDidChangeTextDocument(this.onDocumentUpdate.bind(this)), + vscode.workspace.onDidCloseTextDocument(this.onDocumentClose.bind(this)), + vscode.workspace.onDidSaveTextDocument(this.onDocumentSave.bind(this)), + ); + } + + private readonly map = new Map(); + + private getDocumentContextIfExist(document: vscode.TextDocument) { + return document.languageId === 'texinfo' ? this.getDocumentContext(document) : undefined; + } + + /** + * Jump to the corresponding section of document preview by node name. + * + * @param document + * @param nodeName + */ + private gotoPreview(document: vscode.TextDocument, nodeName: string) { + this.getDocumentContext(document).initPreview().goto(nodeName); + } + + private onDocumentClose(document: vscode.TextDocument) { + this.map.get(document)?.getPreview()?.close(); + this.map.delete(document); + } + + private onDocumentSave(document: vscode.TextDocument) { + const documentContext = this.getDocumentContextIfExist(document); + if (documentContext === undefined) return; + documentContext.foldingRange.clear(); + documentContext.getPreview()?.updateWebview(); + } + + private onDocumentUpdate(event: vscode.TextDocumentChangeEvent) { + const documentContext = this.getDocumentContextIfExist(event.document); if (documentContext?.foldingRange.update(event.contentChanges)) { documentContext.documentSymbol.clear(); } } - static onDocumentSave(document: vscode.TextDocument) { - const documentContext = ContextMapping.getDocumentContextIfExist(document); - if (documentContext !== undefined) { - documentContext.foldingRange.clear(); - documentContext.getPreview()?.updateWebview(); + /** + * Create (if not yet created) and show preview for a Texinfo document. + * + * @param editor The editor where the document is being held. + */ + private async showPreview(editor: vscode.TextEditor) { + const document = editor.document; + // Only show preview for saved files, as we're not gonna send document content to `makeinfo` via STDIN. + // Instead, the file will be loaded from disk. + if (document.isUntitled) { + if (!await prompt('Save this document to display preview.', 'Save')) return; + if (!await document.save()) return; } - } - - static onDocumentClose(document: vscode.TextDocument) { - ContextMapping.instance.value.get(document)?.getPreview()?.close(); - ContextMapping.instance.value.delete(document); - } - - private static getDocumentContextIfExist(document: vscode.TextDocument) { - return document.languageId === 'texinfo' ? ContextMapping.getDocumentContext(document) : undefined; - } - - private readonly value = new Map(); - - dispose() { - this.value.forEach(documentContext => documentContext.getPreview()?.close()); - ContextMapping.singleton = undefined; + this.getDocumentContext(document).initPreview().show(); } } diff --git a/src/contexts/document.ts b/src/contexts/document.ts index f0e150e..07a5906 100644 --- a/src/contexts/document.ts +++ b/src/contexts/document.ts @@ -20,6 +20,7 @@ */ import * as vscode from 'vscode'; +import GlobalContext from '../global_context'; import DocumentSymbolContext from './document_symbol'; import FoldingRangeContext from './folding_range'; import PreviewContext from './preview'; @@ -29,12 +30,10 @@ import PreviewContext from './preview'; */ export default class DocumentContext { - readonly foldingRange = new FoldingRangeContext(this.document); + readonly foldingRange = new FoldingRangeContext(this); readonly documentSymbol = new DocumentSymbolContext(this); - private preview?: PreviewContext; - initPreview() { return this.preview ??= new PreviewContext(this); } @@ -47,5 +46,7 @@ export default class DocumentContext { this.preview = undefined; } - constructor(readonly document: vscode.TextDocument) {} + constructor(readonly globalContext: GlobalContext, readonly document: vscode.TextDocument) {} + + private preview?: PreviewContext; } diff --git a/src/contexts/document_symbol.ts b/src/contexts/document_symbol.ts index 0406aae..ee70a0f 100644 --- a/src/contexts/document_symbol.ts +++ b/src/contexts/document_symbol.ts @@ -29,26 +29,26 @@ import { FoldingRange, Optional } from '../utils/types'; */ export default class DocumentSymbolContext { - private document = this.documentContext.document; - - private documentSymbols?: vscode.DocumentSymbol[]; - - get values() { - return this.documentSymbols ??= this.calculcateDocumentSymbols(); + get documentSymbols() { + return this._documentSymbols ??= this.calculcateDocumentSymbols(); } clear() { - this.documentSymbols = undefined; + this._documentSymbols = undefined; } constructor(private readonly documentContext: DocumentContext) {} + private _documentSymbols?: vscode.DocumentSymbol[]; + + private readonly document = this.documentContext.document; + /** * Calculate document symbols based on folding ranges. */ private calculcateDocumentSymbols() { const ranges = Array>(this.document.lineCount); - this.documentContext.foldingRange.values.forEach(range => range.kind ?? (ranges[range.start] = range)); + this.documentContext.foldingRange.foldingRanges.forEach(range => range.kind ?? (ranges[range.start] = range)); return foldingRangeToSymbols(ranges, 0, ranges.length); } } @@ -60,8 +60,8 @@ function foldingRangeToSymbols(ranges: readonly Optional[], start: if (node === undefined) continue; const range = lineNumToRange(idx, node.end); const selectionRange = lineNumToRange(idx); - const symbol = new vscode.DocumentSymbol('@' + node.name, node.detail, - vscode.SymbolKind.String, range, selectionRange); + const symbol = new vscode.DocumentSymbol('@' + node.name, node.detail, vscode.SymbolKind.String, + range, selectionRange); symbol.children = foldingRangeToSymbols(ranges, idx + 1, node.end); symbols.push(symbol); idx = node.end; diff --git a/src/contexts/folding_range.ts b/src/contexts/folding_range.ts index a9492c1..d383d25 100644 --- a/src/contexts/folding_range.ts +++ b/src/contexts/folding_range.ts @@ -22,6 +22,7 @@ import * as vscode from 'vscode'; import { lineNumToRange } from '../utils/misc'; import { FoldingRange, Range, NamedLine } from '../utils/types'; +import DocumentContext from './document'; /** * Stores information about folding ranges for a document. @@ -31,40 +32,18 @@ import { FoldingRange, Range, NamedLine } from '../utils/types'; */ export default class FoldingRangeContext { - /** - * Regex for matching subsection/section/chapter (-like) commands. - */ - private static readonly nodeFormat = RegExp('^@(?:(node)|(subsection|unnumberedsubsec|appendixsubsec|subheading)|' + - '(section|unnumberedsec|appendixsec|heading)|(chapter|unnumbered|appendix|majorheading|chapheading)) (.*)$'); - - private foldingRanges?: FoldingRange[]; - - private nodes = []; - - private commentRange?: Range; - - private headerStart?: number; - - private closingChapter?: number; - - private closingSection?: number; - - private closingSubsection?: number; - - private contentMayChange = true; - /** * Get VSCode folding ranges from the context. */ - get values() { - return this.foldingRanges ?? this.calculateFoldingRanges(); + get foldingRanges() { + return this._foldingRanges ?? this.calculateFoldingRanges(); } /** * Get node values of document as VSCode code lenses. */ get nodeValues() { - this.foldingRanges ?? this.calculateFoldingRanges(); + this._foldingRanges ?? this.calculateFoldingRanges(); return this.nodes; } @@ -75,12 +54,12 @@ export default class FoldingRangeContext { */ update(events: readonly vscode.TextDocumentContentChangeEvent[]) { this.contentMayChange = true; - if (this.foldingRanges === undefined) return false; + if (this._foldingRanges === undefined) return false; for (const event of events) { const updatedLines = event.text.split(this.document.eol === vscode.EndOfLine.LF ? '\n' : '\r\n').length; // Clear cached folding range when line count changes. if (updatedLines !== 1 || event.range.start.line !== event.range.end.line) { - this.foldingRanges = undefined; + this._foldingRanges = undefined; this.nodes = []; return true; } @@ -90,10 +69,39 @@ export default class FoldingRangeContext { clear() { if (!this.contentMayChange) return; - this.foldingRanges = undefined; + this._foldingRanges = undefined; } - constructor(private readonly document: vscode.TextDocument) {} + constructor(private readonly documentContext: DocumentContext) {} + + private readonly document = this.documentContext.document; + + /** + * Regex for matching subsection/section/chapter (-like) commands. + */ + private static readonly nodeFormat = RegExp('^@(?:(node)|(subsection|unnumberedsubsec|appendixsubsec|subheading)|' + + '(section|unnumberedsec|appendixsec|heading)|(chapter|unnumbered|appendix|majorheading|chapheading)) (.*)$'); + + private _foldingRanges?: FoldingRange[]; + + private nodes = []; + + private commentRange?: Range; + private headerStart?: number; + private closingChapter?: number; + private closingSection?: number; + private closingSubsection?: number; + + private contentMayChange = true; + + private addRange(start: number, end: number, extraArgs: { + name?: string, + detail?: string, + kind?: vscode.FoldingRangeKind + }) { + (this._foldingRanges ??= []) + .push({ name: extraArgs.name ?? '', detail: extraArgs.detail ?? '', start, end, kind: extraArgs.kind }); + } /** * Calculate and update folding ranges for the document. @@ -103,7 +111,7 @@ export default class FoldingRangeContext { */ private calculateFoldingRanges() { this.contentMayChange = false; - this.foldingRanges = []; + this._foldingRanges = []; this.clearTemporaries(); let closingBlocks = []; let lastLine = this.document.lineCount - 1; @@ -115,7 +123,7 @@ export default class FoldingRangeContext { if (line === '@bye') { lastLine = idx; // Abort anything after `@bye`. - this.foldingRanges = []; + this._foldingRanges = []; closingBlocks = []; this.clearTemporaries(); continue; @@ -126,7 +134,9 @@ export default class FoldingRangeContext { if (line.startsWith('@end ')) { if (verbatim) continue; const name = line.substring(5).trimRight(); - name === 'verbatim' && (verbatim = true); + if (name === 'verbatim') { + verbatim = true; + } closingBlocks.push({ name: name, line: idx }); continue; } @@ -144,12 +154,30 @@ export default class FoldingRangeContext { if (this.commentRange !== undefined) { this.addRange(this.commentRange.start, this.commentRange.end, { kind: vscode.FoldingRangeKind.Comment }); } - return this.foldingRanges; + return this._foldingRanges; + } + + private clearTemporaries() { + this.commentRange = undefined; + this.headerStart = undefined; + this.nodes = []; + this.closingSubsection = this.closingSection = this.closingChapter = undefined; + } + + private getLastTextLine(lineNum: number, limit = 3) { + for (let idx = lineNum; idx > lineNum - limit; --idx) { + const line = this.document.lineAt(idx).text; + if (line.startsWith('@node ')) return idx - 1; + if (line === '') return idx; + } + return lineNum; } private processComment(lineText: string, lineNum: number) { if (!lineText.startsWith('@c')) return false; - if (!lineText.startsWith(' ', 2) && !lineText.startsWith('omment ', 2)) return false; + if (!lineText.startsWith(' ', 2) && !lineText.startsWith('omment ', 2)) { + return false; + } // Check for opening/closing header. if (lineText.startsWith('%**', lineText[2] === ' ' ? 3 : 9)) { if (this.headerStart === undefined) { @@ -203,29 +231,4 @@ export default class FoldingRangeContext { } return false; } - - private getLastTextLine(lineNum: number, limit = 3) { - for (let idx = lineNum; idx > lineNum - limit; --idx) { - const line = this.document.lineAt(idx).text; - if (line.startsWith('@node ')) return idx - 1; - if (line === '') return idx; - } - return lineNum; - } - - private addRange(start: number, end: number, extraArgs: { - name?: string, - detail?: string, - kind?: vscode.FoldingRangeKind - }) { - (this.foldingRanges ??= []) - .push({ name: extraArgs.name ?? '', detail: extraArgs.detail ?? '', start, end, kind: extraArgs.kind }); - } - - private clearTemporaries() { - this.commentRange = undefined; - this.headerStart = undefined; - this.nodes = []; - this.closingSubsection = this.closingSection = this.closingChapter = undefined; - } } diff --git a/src/contexts/preview.ts b/src/contexts/preview.ts index e27915b..ad36f4d 100644 --- a/src/contexts/preview.ts +++ b/src/contexts/preview.ts @@ -22,10 +22,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; import DocumentContext from './document'; -import ContextMapping from '../context_mapping'; -import Diagnosis from '../diagnosis'; -import Logger from '../logger'; -import Options from '../options'; import Converter from '../utils/converter'; import { getNodeHtmlRef, prompt } from '../utils/misc'; import { Operator, Optional } from '../utils/types'; @@ -35,81 +31,20 @@ import { Operator, Optional } from '../utils/types'; */ export default class PreviewContext { - /** - * Create (if not yet created) and show preview for a Texinfo document. - * - * @param editor The editor where the document is being held. - */ - static async showPreview(editor: vscode.TextEditor) { - const document = editor.document; - // Only show preview for saved files, as we're not gonna send document content to `makeinfo` via STDIN. - // Instead, the file will be loaded from disk. - if (document.isUntitled) { - if (!await prompt('Save this document to display preview.', 'Save')) return; - if (!await document.save()) return; - } - ContextMapping.getDocumentContext(document).initPreview().panel.reveal(); - } - - /** - * Jump to the corresponding section of document preview by node name. - * - * @param document - * @param nodeName - */ - static gotoPreview(document: vscode.TextDocument, nodeName: string) { - ContextMapping.getDocumentContext(document).initPreview().panel.webview - .postMessage({ command: 'goto', value: getNodeHtmlRef(nodeName) }); - } - - private readonly document = this.documentContext.document; - - private readonly panel: vscode.WebviewPanel; - - private readonly disposables = []; - - /** - * Whether the preview is updating. - */ - private updating = false; - - /** - * Whether a preview update request is pending. - */ - private pendingUpdate = false; - - private get imageTransformer(): Optional> { - if (!Options.localImage) return undefined; - const pathName = path.dirname(this.document.fileName); - return src => { - // Do not transform URIs of online images. - if (src.startsWith('https://') || src.startsWith('http://')) return src; - const srcUri = vscode.Uri.file(pathName + '/' + src); - // To display images in webviews, image URIs in HTML should be converted to VSCode-recognizable ones. - return this.panel.webview.asWebviewUri(srcUri).toString(); - }; - } - - private get script() { - if (!Options.enableCodeLens) return undefined; - return "window.addEventListener('message', event => {" + - "const message = event.data;" + - "switch (message.command) {" + - "case 'goto':" + - "window.location.hash = message.value;" + - // We may want to scroll to the same node again. - "history.pushState('', '', window.location.pathname);" + - "break;" + - "}" + - "})"; - } - close() { this.disposables.forEach(event => event.dispose()); this.panel.dispose(); this.documentContext.closePreview(); // Only show diagnostic information when the preview is active. - Diagnosis.delete(this.document); + this.diagnosis.delete(this.document); + } + + goto(nodeName: string) { + this.panel.webview.postMessage({ command: 'goto', value: getNodeHtmlRef(nodeName) }); + } + + show() { + this.panel.reveal(); } async updateWebview() { @@ -121,17 +56,17 @@ export default class PreviewContext { this.pendingUpdate = false; // Inform the user that the preview is updating if `makeinfo` takes too long. setTimeout(() => this.updating && this.updateTitle(), 500); - const { data, error } = await new Converter(this.document.fileName) + const { data, error } = await new Converter(this.document.fileName, this.globalContext.options, this.logger) .convertToHtml(this.imageTransformer, this.script); if (error) { - Logger.log(error); - Diagnosis.update(this.document, error); + this.logger.log(error); + this.diagnosis.update(this.document, error); } else { - Diagnosis.delete(this.document); + this.diagnosis.delete(this.document); } if (data === undefined) { prompt(`Failed to show preview for ${this.document.fileName}.`, 'Show log', true) - .then(result => result && Logger.show()); + .then(result => result && this.logger.show()); } else { this.panel.webview.html = data; } @@ -148,6 +83,51 @@ export default class PreviewContext { this.updateWebview(); } + private readonly document = this.documentContext.document; + private readonly globalContext = this.documentContext.globalContext; + private readonly diagnosis = this.globalContext.diagnosis; + private readonly logger = this.globalContext.logger; + + private readonly disposables = []; + + private readonly panel: vscode.WebviewPanel; + + /** + * Whether a preview update request is pending. + */ + private pendingUpdate = false; + + /** + * Whether the preview is updating. + */ + private updating = false; + + private get imageTransformer(): Optional> { + if (!this.globalContext.options.localImage) return undefined; + const pathName = path.dirname(this.document.fileName); + return src => { + // Do not transform URIs of online images. + if (src.startsWith('https://') || src.startsWith('http://')) return src; + const srcUri = vscode.Uri.file(pathName + '/' + src); + // To display images in webviews, image URIs in HTML should be converted to VSCode-recognizable ones. + return this.panel.webview.asWebviewUri(srcUri).toString(); + }; + } + + private get script() { + if (!this.globalContext.options.enableCodeLens) return undefined; + return "window.addEventListener('message', event => {" + + "const message = event.data;" + + "switch (message.command) {" + + "case 'goto':" + + "window.location.hash = message.value;" + + // We may want to scroll to the same node again. + "history.pushState('', '', window.location.pathname);" + + "break;" + + "}" + + "})"; + } + private updateTitle() { const updating = this.updating ? '(Updating) ' : ''; const fileName = path.basename(this.document.fileName); diff --git a/src/diagnosis.ts b/src/diagnosis.ts index 40af301..f349657 100644 --- a/src/diagnosis.ts +++ b/src/diagnosis.ts @@ -28,10 +28,8 @@ import { isDefined } from './utils/types'; */ export default class Diagnosis implements vscode.Disposable { - private static singleton?: Diagnosis; - - static get instance() { - return Diagnosis.singleton ??= new Diagnosis(); + delete(document: vscode.TextDocument) { + this.diagnostics.delete(document.uri); } /** @@ -40,29 +38,26 @@ export default class Diagnosis implements vscode.Disposable { * @param document * @param logText */ - static update(document: vscode.TextDocument, logText: string) { + update(document: vscode.TextDocument, logText: string) { const fileName = document.uri.path; - const diagnostics = logText.split('\n').filter(line => line.startsWith(fileName)) - .map(line => logLineToDiagnostic(line.substring(fileName.length + 1))).filter(isDefined); - Diagnosis.instance.diagnostics.set(document.uri, diagnostics); + const diagnostics = logText.split('\n') + .filter(line => line.startsWith(fileName)) + .map(line => logLineToDiagnostic(line.substring(fileName.length + 1))) + .filter(isDefined); + this.diagnostics.set(document.uri, diagnostics); } - static delete(document: vscode.TextDocument) { - Diagnosis.instance.diagnostics.delete(document.uri); - } - - private readonly diagnostics = vscode.languages.createDiagnosticCollection('texinfo'); - dispose() { this.diagnostics.dispose(); - Diagnosis.singleton = undefined; } + + private readonly diagnostics = vscode.languages.createDiagnosticCollection('texinfo'); } function logLineToDiagnostic(lineText: string) { - const lineNum = Number.parseInt(lineText) - 1; + const lineNum = parseInt(lineText) - 1; // Ignore error that does not correspond a line. - if (Number.isNaN(lineNum)) return undefined; + if (isNaN(lineNum)) return undefined; const message = lineText.substring(lineNum.toString().length + 2); const severity = message.startsWith('warning:') ? vscode.DiagnosticSeverity.Warning : undefined; return new vscode.Diagnostic(lineNumToRange(lineNum), message, severity); diff --git a/src/extension.ts b/src/extension.ts index 059927e..5a64218 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,31 +20,8 @@ */ import * as vscode from 'vscode'; -import ContextMapping from './context_mapping'; -import Diagnosis from './diagnosis'; -import Indicator from './indicator'; -import Logger from './logger'; -import Options from './options'; -import PreviewContext from './contexts/preview'; -import CodeLensProvider from './providers/code_lens'; -import CompletionItemProvider from './providers/completion_item'; -import DocumentSymbolProvider from './providers/document_symbol'; -import FoldingRangeProvider from './providers/folding_range'; +import GlobalContext from './global_context'; export function activate(context: vscode.ExtensionContext) { - context.subscriptions.push( - ContextMapping.instance, Diagnosis.instance, Indicator.instance, Logger.instance, Options.instance, - vscode.window.onDidChangeActiveTextEditor(Indicator.onTextEditorChange), - vscode.workspace.onDidChangeTextDocument(ContextMapping.onDocumentUpdate), - vscode.workspace.onDidSaveTextDocument(ContextMapping.onDocumentSave), - vscode.workspace.onDidCloseTextDocument(ContextMapping.onDocumentClose), - vscode.workspace.onDidChangeConfiguration(Options.clear), - vscode.commands.registerTextEditorCommand('texinfo.preview.show', PreviewContext.showPreview), - vscode.commands.registerCommand('texinfo.preview.goto', PreviewContext.gotoPreview), - vscode.commands.registerCommand('texinfo.indicator.click', Indicator.click), - vscode.languages.registerCodeLensProvider('texinfo', new CodeLensProvider()), - vscode.languages.registerCompletionItemProvider('texinfo', new CompletionItemProvider(), '@'), - vscode.languages.registerDocumentSymbolProvider('texinfo', new DocumentSymbolProvider()), - vscode.languages.registerFoldingRangeProvider('texinfo', new FoldingRangeProvider()), - ); + new GlobalContext(context); } diff --git a/src/global_context.ts b/src/global_context.ts new file mode 100644 index 0000000..ef5342a --- /dev/null +++ b/src/global_context.ts @@ -0,0 +1,72 @@ +/** + * global_context.ts + * + * Copyright (C) 2021 CismonX + * + * This file is part of vscode-texinfo. + * + * vscode-texinfo is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * vscode-texinfo is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along with + * vscode-texinfo. If not, see . + */ + +import * as vscode from 'vscode'; +import ContextMapping from './context_mapping'; +import Diagnosis from './diagnosis'; +import Indicator from './indicator'; +import Logger from './logger'; +import Options from './options'; +import CodeLensProvider from './providers/code_lens'; +import CompletionItemProvider from './providers/completion_item'; +import DocumentSymbolProvider from './providers/document_symbol'; +import FoldingRangeProvider from './providers/folding_range'; + +/** + * Manage extension-level global-scope contexts. + */ +export default class GlobalContext { + + readonly contextMapping = new ContextMapping(this); + + readonly diagnosis = new Diagnosis; + + readonly indicator = new Indicator(this); + + readonly logger = new Logger; + + /** + * Note: `Options`' no singleton. Do not wire directly, always use `globalContext.options` instead. + */ + get options() { + return this._options ??= new Options; + } + + subscribe(...items: vscode.Disposable[]) { + this.context.subscriptions.push(...items); + } + + constructor(private readonly context: vscode.ExtensionContext) { + this.subscribe(this.contextMapping, this.diagnosis, this.indicator, this.logger, + vscode.languages.registerCodeLensProvider('texinfo', new CodeLensProvider(this)), + vscode.languages.registerCompletionItemProvider('texinfo', new CompletionItemProvider(this), '@'), + vscode.languages.registerDocumentSymbolProvider('texinfo', new DocumentSymbolProvider(this)), + vscode.languages.registerFoldingRangeProvider('texinfo', new FoldingRangeProvider(this)), + vscode.workspace.onDidChangeConfiguration(this.refreshOptions), + ); + } + + private _options?: Options; + + private refreshOptions() { + this._options = undefined; + } +} diff --git a/src/indicator.ts b/src/indicator.ts index 5219d84..b5560a5 100644 --- a/src/indicator.ts +++ b/src/indicator.ts @@ -20,7 +20,7 @@ */ import * as vscode from 'vscode'; -import Options from './options'; +import GlobalContext from './global_context'; import { exec } from './utils/misc'; /** @@ -28,66 +28,61 @@ import { exec } from './utils/misc'; */ export default class Indicator implements vscode.Disposable { - private static singleton?: Indicator; - - static async click() { - await Indicator.instance.updateStatus(); - Indicator.instance.refresh(vscode.window.activeTextEditor); - } - - static onTextEditorChange(editor?: vscode.TextEditor) { - Indicator.instance.refresh(editor); - } - - static get instance() { - return this.singleton ??= new Indicator(); - } - - private statusBarItem: vscode.StatusBarItem; - - private gnuTexinfoAvailable = false; - get canDisplayPreview() { - return this.gnuTexinfoAvailable; + return this._canDisplayPreview; + } + + dispose() { + this.statusBarItem.dispose(); + } + + constructor(private readonly globalContext: GlobalContext) { + globalContext.subscribe( + vscode.commands.registerCommand('texinfo.indicator.click', this.click.bind(this)), + vscode.window.onDidChangeActiveTextEditor(this.refresh.bind(this)), + ); + this.updateStatus().then(() => this.refresh(vscode.window.activeTextEditor)); + } + + private _canDisplayPreview = false; + + private statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + + private async click() { + await this.updateStatus(); + this.refresh(vscode.window.activeTextEditor); } private refresh(editor?: vscode.TextEditor) { - if (editor === undefined || editor.document.languageId != 'texinfo') { - this.statusBarItem.hide(); - } else { + if (editor?.document.languageId === 'texinfo') { this.statusBarItem.show(); + } else { + this.statusBarItem.hide(); } } private async updateStatus() { - const output = await exec(Options.makeinfo, ['--version'], Options.maxSize); + const options = this.globalContext.options; + const output = await exec(options.makeinfo, ['--version'], options.maxSize); const result = output.data?.match(/\(GNU texinfo\) (.*)\n/); let tooltip = '', icon: string, version = ''; if (result && result[1]) { version = result[1]; if (!isNaN(+version) && +version < 6.7) { icon = '$(warning)'; - tooltip = `GNU Texinfo (${Options.makeinfo}) is outdated (${version} < 6.7).`; + tooltip = `GNU Texinfo (${options.makeinfo}) is outdated (${version} < 6.7).`; } else { + // Unrecognizable version. Assume it is okay. icon = '$(check)'; } - this.gnuTexinfoAvailable = true; + this._canDisplayPreview = true; } else { icon = '$(close)'; - tooltip = `GNU Texinfo (${Options.makeinfo}) is not correctly installed or configured.`; - this.gnuTexinfoAvailable = false; + tooltip = `GNU Texinfo (${options.makeinfo}) is not correctly installed or configured.`; + this._canDisplayPreview = false; } + this.statusBarItem.command = 'texinfo.indicator.click'; this.statusBarItem.text = `${icon} GNU Texinfo ${version}`; this.statusBarItem.tooltip = tooltip; - this.statusBarItem.command = 'texinfo.indicator.click'; - } - - private constructor() { - this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - this.updateStatus().then(() => this.refresh(vscode.window.activeTextEditor)); - } - - dispose() { - this.statusBarItem.dispose(); } } diff --git a/src/logger.ts b/src/logger.ts index 3a21887..fb47d0d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -26,29 +26,18 @@ import * as vscode from 'vscode'; */ export default class Logger implements vscode.Disposable { - private static singleton?: Logger; - - static get instance() { - return Logger.singleton ??= new Logger(); - } - - static log(message: string) { + log(message: string) { const dateTime = new Date().toLocaleString(undefined, { hour12: false }); - Logger.instance.outputChannel.appendLine(`[ ${dateTime} ]\n${message}`); + this.outputChannel.appendLine(`[ ${dateTime} ]\n${message}`); } - static show() { - Logger.instance.outputChannel.show(true); - } - - private outputChannel: vscode.OutputChannel; - - private constructor() { - this.outputChannel = vscode.window.createOutputChannel('Texinfo'); + show() { + this.outputChannel.show(true); } dispose() { - Logger.instance.outputChannel.dispose(); - Logger.singleton = undefined; + this.outputChannel.dispose(); } + + private outputChannel = vscode.window.createOutputChannel('Texinfo'); } diff --git a/src/options.ts b/src/options.ts index 23b8037..7047ed7 100644 --- a/src/options.ts +++ b/src/options.ts @@ -24,69 +24,55 @@ import * as vscode from 'vscode'; /** * Fetch extension option values. * - * See `contributes.configuration` of package.json for details. + * See the `contributes.configuration` entry in package.json for details. */ -export default class Options implements vscode.Disposable { +export default class Options { - private static singleton?: Options; - - static get instance() { - return Options.singleton ??= new Options('texinfo'); + get enableSnippets() { + return this.getBoolean('completion.enableSnippets'); } - static get makeinfo() { - return Options.instance.getString('makeinfo'); + get hideSnippetCommands() { + return this.getBoolean('completion.hideSnippetCommands'); } - static get enableCodeLens() { - return Options.instance.getBoolean('enableCodeLens'); + get enableCodeLens() { + return this.getBoolean('enableCodeLens'); } - static get enableSnippets() { - return Options.instance.getBoolean('completion.enableSnippets'); + get makeinfo() { + return this.getString('makeinfo'); } - static get hideSnippetCommands() { - return Options.instance.getBoolean('completion.hideSnippetCommands'); + get customCSS() { + return this.getString('preview.customCSS'); } - static get noHeaders() { - return Options.instance.getBoolean('preview.noHeaders'); + get errorLimit() { + return this.getNumber('preview.errorLimit'); } - static get maxSize() { - return Options.instance.getNumber('preview.maxSize') * 1024 * 1024; + get localImage() { + return this.getBoolean('preview.localImage'); } - static get errorLimit() { - return Options.instance.getNumber('preview.errorLimit'); + get maxSize() { + return this.getNumber('preview.maxSize') * 1024 * 1024; } - static get noValidation() { - return Options.instance.getBoolean('preview.noValidation'); + get noHeaders() { + return this.getBoolean('preview.noHeaders'); } - static get noWarnings() { - return Options.instance.getBoolean('preview.noWarnings'); + get noValidation() { + return this.getBoolean('preview.noValidation'); } - static get localImage() { - return Options.instance.getBoolean('preview.localImage'); + get noWarnings() { + return this.getBoolean('preview.noWarnings'); } - static get customCSS() { - return Options.instance.getString('preview.customCSS'); - } - - static clear() { - Options.singleton = undefined; - } - - private readonly configuration: vscode.WorkspaceConfiguration; - - private getString(section: string) { - return this.configuration.get(section, ''); - } + private readonly configuration = vscode.workspace.getConfiguration('texinfo'); private getBoolean(section: string) { return this.configuration.get(section, false); @@ -96,11 +82,7 @@ export default class Options implements vscode.Disposable { return this.configuration.get(section, 0); } - private constructor(section: string) { - this.configuration = vscode.workspace.getConfiguration(section); - } - - dispose() { - Options.singleton = undefined; + private getString(section: string) { + return this.configuration.get(section, ''); } } diff --git a/src/providers/code_lens.ts b/src/providers/code_lens.ts index 3ec7f92..f9031a8 100644 --- a/src/providers/code_lens.ts +++ b/src/providers/code_lens.ts @@ -20,9 +20,7 @@ */ import * as vscode from 'vscode'; -import ContextMapping from '../context_mapping'; -import Indicator from '../indicator'; -import Options from '../options'; +import GlobalContext from '../global_context'; /** * Provide code lenses for Texinfo document. @@ -30,8 +28,10 @@ import Options from '../options'; export default class CodeLensProvider implements vscode.CodeLensProvider { provideCodeLenses(document: vscode.TextDocument) { - if (!Options.enableCodeLens) return undefined; - if (!Indicator.instance.canDisplayPreview) return undefined; - return ContextMapping.getDocumentContext(document).foldingRange.nodeValues; + if (!this.globalContext.options.enableCodeLens) return undefined; + if (!this.globalContext.indicator.canDisplayPreview) return undefined; + return this.globalContext.contextMapping.getDocumentContext(document).foldingRange.nodeValues; } + + constructor(private readonly globalContext: GlobalContext) {} } diff --git a/src/providers/completion_item.ts b/src/providers/completion_item.ts index 336c5de..0bfa4b0 100644 --- a/src/providers/completion_item.ts +++ b/src/providers/completion_item.ts @@ -20,7 +20,7 @@ */ import * as vscode from 'vscode'; -import Options from '../options'; +import GlobalContext from '../global_context'; import { CompletionItem } from '../utils/types'; /** @@ -28,8 +28,6 @@ import { CompletionItem } from '../utils/types'; */ export default class CompletionItemProvider implements vscode.CompletionItemProvider { - private completionItems?: CompletionItem[]; - /** * Full list of completion items. * @@ -41,10 +39,10 @@ export default class CompletionItemProvider implements vscode.CompletionItemProv * which means that GFDL applies to lines 48-398 of this file, while the remainder * is under GPL like other source code files of the project. */ - private get values() { - const enableSnippets = Options.enableSnippets; - const hideSnippetCommands = Options.hideSnippetCommands; - return this.completionItems ??= [ + private get completionItems() { + const enableSnippets = this.oldOptions.enableSnippets; + const hideSnippetCommands = this.oldOptions.hideSnippetCommands; + return this._completionItems ??= [ command('ampchar', 'Insert an ampersand, "&"', { hasEmptyBrace: true }), command('atchar', 'Insert an at sign, "@"', { hasEmptyBrace: true }), command('backslashchar', 'Insert a blackslash, "\\"', { hasEmptyBrace: true }), @@ -402,8 +400,6 @@ export default class CompletionItemProvider implements vscode.CompletionItemProv }); } - private oldOptions?: Options; - provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, @@ -422,18 +418,25 @@ export default class CompletionItemProvider implements vscode.CompletionItemProv if (document.getText(new vscode.Range(position.translate(0, -1), position)) !== '@') return undefined; } // Check whether options has changed. - if (this.oldOptions !== Options.instance) { - this.oldOptions = Options.instance; - this.completionItems = undefined; + const newOptions = this.globalContext.options; + if (this.oldOptions !== newOptions) { + this.oldOptions = newOptions; + this._completionItems = undefined; } - if (position.character === 1) return this.values; + if (position.character === 1) return this.completionItems; // Check whether the '@' character is escaped. if (document.getText(new vscode.Range(position.translate(0, -2), position.translate(0, -1))) === '@') { return undefined; } else { - return this.values; + return this.completionItems; } } + + constructor(private readonly globalContext: GlobalContext) {} + + private _completionItems?: CompletionItem[]; + + private oldOptions = this.globalContext.options; } /** diff --git a/src/providers/document_symbol.ts b/src/providers/document_symbol.ts index b5b47d3..e1005bb 100644 --- a/src/providers/document_symbol.ts +++ b/src/providers/document_symbol.ts @@ -20,7 +20,7 @@ */ import * as vscode from 'vscode'; -import ContextMapping from '../context_mapping'; +import GlobalContext from '../global_context'; /** * Provide document symbol information for Texinfo documents. @@ -28,6 +28,8 @@ import ContextMapping from '../context_mapping'; export default class DocumentSymbolProvider implements vscode.DocumentSymbolProvider { provideDocumentSymbols(document: vscode.TextDocument) { - return ContextMapping.getDocumentContext(document).documentSymbol.values; + return this.globalContext.contextMapping.getDocumentContext(document).documentSymbol.documentSymbols; } + + constructor(private readonly globalContext: GlobalContext) {} } diff --git a/src/providers/folding_range.ts b/src/providers/folding_range.ts index 1352bbf..355ca93 100644 --- a/src/providers/folding_range.ts +++ b/src/providers/folding_range.ts @@ -20,7 +20,7 @@ */ import * as vscode from 'vscode'; -import ContextMapping from '../context_mapping'; +import GlobalContext from '../global_context'; /** * Provide folding range info for Texinfo documents. @@ -28,6 +28,8 @@ import ContextMapping from '../context_mapping'; export default class FoldingRangeProvider implements vscode.FoldingRangeProvider { provideFoldingRanges(document: vscode.TextDocument) { - return ContextMapping.getDocumentContext(document).foldingRange.values; + return this.globalContext.contextMapping.getDocumentContext(document).foldingRange.foldingRanges; } + + constructor(private readonly globalContext: GlobalContext) {} } diff --git a/src/utils/converter.ts b/src/utils/converter.ts index 6d2976a..d5d7a9b 100644 --- a/src/utils/converter.ts +++ b/src/utils/converter.ts @@ -31,6 +31,25 @@ import { Operator } from './types'; */ export default class Converter { + async convertToHtml(imgTransformer?: Operator, insertScript?: string) { + const options = ['-o-', '--no-split', '--html', `--error-limit=${this.options.errorLimit}`]; + this.options.noHeaders && options.push('--no-headers'); + this.options.noValidation && options.push('--no-validate'); + this.options.noWarnings && options.push('--no-warn'); + this.options.customCSS && this.includeCustomCSS(this.options.customCSS, options); + const result = await exec(this.options.makeinfo, options.concat(this.path), this.options.maxSize); + if (result.data !== undefined) { + // No worry about performance here, as the DOM is lazily initialized. + const dom = new DOM(result.data); + imgTransformer && dom.transformImageUri(imgTransformer); + insertScript && dom.insertScript(insertScript); + result.data = dom.outerHTML; + } + return result; + } + + constructor(private readonly path: string, private readonly options: Options, private readonly logger: Logger) {} + private includeCustomCSS(cssFileURI: string, options: string[]) { try { const uri = vscode.Uri.parse(cssFileURI, true); @@ -46,26 +65,7 @@ export default class Converter { throw URIError; } } catch (e) { - Logger.log(`Cannot load custom CSS. Invalid URI: '${cssFileURI}'`); + this.logger.log(`Cannot load custom CSS. Invalid URI: '${cssFileURI}'`); } } - - constructor(private readonly path: string) {} - - async convertToHtml(imgTransformer?: Operator, insertScript?: string) { - const options = ['-o', '-', '--no-split', '--html', `--error-limit=${Options.errorLimit}`]; - Options.noHeaders && options.push('--no-headers'); - Options.noValidation && options.push('--no-validate'); - Options.noWarnings && options.push('--no-warn'); - Options.customCSS && this.includeCustomCSS(Options.customCSS, options); - const result = await exec(Options.makeinfo, options.concat(this.path), Options.maxSize); - if (result.data !== undefined) { - // No worry about performance here, as the DOM is lazily initialized. - const dom = new DOM(result.data); - imgTransformer && dom.transformImageUri(imgTransformer); - insertScript && dom.insertScript(insertScript); - result.data = dom.outerHTML; - } - return result; - } } diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 96842fe..55ec77c 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -23,18 +23,10 @@ import * as htmlparser from 'node-html-parser'; import { Operator } from './types'; /** - * DOM manipulation utilities. + * Parse HTML into DOM and transform elements. */ export default class DOM { - private dom?: htmlparser.HTMLElement; - - private changed = false; - - private get value() { - return this.dom ??= htmlparser.parse(this.html); - } - get outerHTML() { if (this.changed) { this.html = this.value.outerHTML; @@ -43,6 +35,11 @@ export default class DOM { return this.html; } + insertScript(script: string) { + this.value.querySelector('head').insertAdjacentHTML('beforeend', ``); + this.changed = true; + } + /** * Transform and replace the `src` attribute value of all `img` elements from HTML using given function. * @@ -58,10 +55,13 @@ export default class DOM { this.changed = true; } - insertScript(script: string) { - this.value.querySelector('head').insertAdjacentHTML('beforeend', ``); - this.changed = true; - } - constructor(private html: string) {} + + private _value?: htmlparser.HTMLElement; + + private changed = false; + + private get value() { + return this._value ??= htmlparser.parse(this.html); + } } diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 5c25585..26ba645 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -32,15 +32,9 @@ import { ExecResult } from './types'; * @returns The output data, or `undefined` if execution fails. */ export function exec(path: string, args: string[], maxBuffer: number) { - return new Promise(resolve => { - child_process.execFile(path, args, { maxBuffer: maxBuffer }, (error, stdout, stderr) => { - if (error) { - resolve({ error: stderr ? stderr : error.message }); - } else { - resolve({ data: stdout, error: stderr }); - } - }); - }); + return new Promise(resolve => child_process.execFile(path, args, { maxBuffer: maxBuffer }, + (error, stdout, stderr) => resolve( + error ? { error: stderr ? stderr : error.message } : { data: stdout, error: stderr }))); } /** diff --git a/src/utils/types.ts b/src/utils/types.ts index 28591c2..626b152 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -21,19 +21,19 @@ import * as vscode from 'vscode'; -export type Optional = T | undefined; - -export type Operator = (arg: T) => T; - -export type Range = { start: number, end: number }; - -export type NamedLine = { name: string, line: number }; +export type CompletionItem = vscode.CompletionItem & { snippet?: boolean }; export type ExecResult = { data?: string, error: string }; export type FoldingRange = vscode.FoldingRange & { name: string, detail: string }; -export type CompletionItem = vscode.CompletionItem & { snippet?: boolean }; +export type NamedLine = { name: string, line: number }; + +export type Operator = (arg: T) => T; + +export type Optional = T | undefined; + +export type Range = { start: number, end: number }; export function isDefined(value: Optional): value is T { return value !== undefined;