diff --git a/package.json b/package.json index c27acda..480c81a 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,10 @@ "command": "texinfo.preview.show", "title": "Show preview", "icon": "$(open-preview)" + }, + { + "command": "texinfo.preview.goto", + "title": "Goto node in preview" } ], "menus": { @@ -90,6 +94,11 @@ "command": "texinfo.preview.show", "when": "editorLangId == texinfo", "group": "navigation" + }, + { + "command": "texinfo.preview.goto", + "when": "false", + "group": "navigation" } ], "editor/title": [ @@ -116,6 +125,11 @@ "default": "makeinfo", "markdownDescription": "Path to the `makeinfo` (or `texi2any`) command. If not located in `$PATH`, an absolute path should be specified.\n\nThe value should not contain any command-line arguments, just the filename." }, + "texinfo.enableCodeLens": { + "type": "boolean", + "default": true, + "markdownDescription": "Enable code lens on node identifiers which jumps to the corresponding nodes in preview." + }, "texinfo.completion.enableSnippets": { "type": "boolean", "default": true, diff --git a/src/contexts/folding_range.ts b/src/contexts/folding_range.ts index a8407f8..93e1169 100644 --- a/src/contexts/folding_range.ts +++ b/src/contexts/folding_range.ts @@ -6,17 +6,21 @@ */ import * as vscode from 'vscode'; +import { lineNumToRange } from '../utils/misc'; import { FoldingRange, Range, NamedLine } from '../utils/types'; /** * Stores information about folding ranges for a document. + * + * Actually, more than folding ranges (e.g. code lens) is handled within this context, so I believe + * we should use another name... */ export default class FoldingRangeContext { /** * Regex for matching subsection/section/chapter (-like) commands. */ - private static nodeMatcher = new RegExp('^@(?:(subsection|unnumberedsubsec|appendixsubsec|subheading)|' + + private static readonly nodeFormat = RegExp('^@(?:(node)|(subsection|unnumberedsubsec|appendixsubsec|subheading)|' + '(section|unnumberedsec|appendixsec|heading)|(chapter|unnumbered|appendix|majorheading|chapheading)) (.*)$'); /** @@ -26,8 +30,18 @@ export default class FoldingRangeContext { return this.foldingRanges ?? this.calculateFoldingRanges(); } + /** + * Get node values of document as VSCode code lenses. + */ + get nodeValues() { + this.foldingRanges ?? this.calculateFoldingRanges(); + return this.nodes; + } + private foldingRanges?: FoldingRange[]; + private nodes = []; + private commentRange?: Range; private headerStart?: number; @@ -50,6 +64,7 @@ export default class FoldingRangeContext { // Clear cached folding range when line count changes. if (updatedLines !== 1 || event.range.start.line !== event.range.end.line) { this.foldingRanges = undefined; + this.nodes = []; return true; } } @@ -134,23 +149,32 @@ export default class FoldingRangeContext { constructor(private readonly document: vscode.TextDocument) {} private processNode(lineText: string, lineNum: number, lastLineNum: number) { - const result = lineText.match(FoldingRangeContext.nodeMatcher); + const result = lineText.match(FoldingRangeContext.nodeFormat); if (result === null) return false; - // Subsection level node. + // Node identifier. if (result[1] !== undefined) { - this.addRange(lineNum, this.closingSubsection ?? lastLineNum, { name: result[1], detail: result[4] }); + this.nodes.push(new vscode.CodeLens(lineNumToRange(lineNum), { + title: '$(search-goto-file) Goto node in preview', + command: 'texinfo.preview.goto', + arguments: [this.document, result[5]], + })); + return true; + } + // Subsection level node. + if (result[2] !== undefined) { + this.addRange(lineNum, this.closingSubsection ?? lastLineNum, { name: result[2], detail: result[5] }); this.closingSubsection = this.getLastTextLine(lineNum - 1); return true; } // Section level node. - if (result[2] !== undefined) { - this.addRange(lineNum, this.closingSection ?? lastLineNum, { name: result[2], detail: result[4] }); + if (result[3] !== undefined) { + this.addRange(lineNum, this.closingSection ?? lastLineNum, { name: result[3], detail: result[5] }); this.closingSubsection = this.closingSection = this.getLastTextLine(lineNum - 1); return true; } // Chapter level node. - if (result[3] !== undefined) { - this.addRange(lineNum, this.closingChapter ?? lastLineNum, { name: result[3], detail: result[4] }); + if (result[4] !== undefined) { + this.addRange(lineNum, this.closingChapter ?? lastLineNum, { name: result[4], detail: result[5] }); this.closingSubsection = this.closingSection = this.closingChapter = this.getLastTextLine(lineNum - 1); return true; } @@ -178,6 +202,7 @@ export default class FoldingRangeContext { 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 51cbb58..d41e1cd 100644 --- a/src/contexts/preview.ts +++ b/src/contexts/preview.ts @@ -13,7 +13,7 @@ import Diagnosis from '../diagnosis'; import Logger from '../logger'; import Options from '../options'; import Converter from '../utils/converter'; -import { prompt } from '../utils/misc'; +import { getNodeHtmlRef, prompt } from '../utils/misc'; import { Operator, Optional } from '../utils/types'; /** @@ -37,6 +37,17 @@ export default class PreviewContext { 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; @@ -70,7 +81,8 @@ 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, this.imageTransformer).convert(); + const { data, error } = await new Converter(this.document.fileName, this.imageTransformer, this.script) + .convert(); if (error) { Logger.log(error); Diagnosis.update(this.document, error); @@ -97,9 +109,7 @@ export default class PreviewContext { } private get imageTransformer(): Optional> { - if (!Options.displayImage) { - return undefined; - } + if (!Options.displayImage) return undefined; const pathName = path.dirname(this.document.fileName); return src => { const srcUri = vscode.Uri.file(pathName + '/' + src); @@ -108,6 +118,18 @@ export default class PreviewContext { }; } + 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;" + + "break;" + + "}" + + "})"; + } + private updateTitle() { const updating = this.updating ? '(Updating) ' : ''; const fileName = path.basename(this.document.fileName); diff --git a/src/extension.ts b/src/extension.ts index 344b254..ffcd100 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import Diagnosis from './diagnosis'; 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'; @@ -23,6 +24,8 @@ export function activate(context: vscode.ExtensionContext) { 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.languages.registerCodeLensProvider('texinfo', new CodeLensProvider()), vscode.languages.registerCompletionItemProvider('texinfo', new CompletionItemProvider(), '@'), vscode.languages.registerDocumentSymbolProvider('texinfo', new DocumentSymbolProvider()), vscode.languages.registerFoldingRangeProvider('texinfo', new FoldingRangeProvider()), diff --git a/src/options.ts b/src/options.ts index b142066..6405ce7 100644 --- a/src/options.ts +++ b/src/options.ts @@ -24,6 +24,10 @@ export default class Options implements vscode.Disposable { return Options.instance.getString('makeinfo'); } + static get enableCodeLens() { + return Options.instance.getBoolean('enableCodeLens'); + } + static get enableSnippets() { return Options.instance.getBoolean('completion.enableSnippets'); } diff --git a/src/providers/code_lens.ts b/src/providers/code_lens.ts new file mode 100644 index 0000000..f39d67c --- /dev/null +++ b/src/providers/code_lens.ts @@ -0,0 +1,21 @@ +/** + * providers/code_lens.ts + * + * @author CismonX + * @license MIT + */ + +import * as vscode from 'vscode'; +import ContextMapping from '../context_mapping'; +import Options from '../options'; + +/** + * Provide code lenses for Texinfo document. + */ +export default class CodeLensProvider implements vscode.CodeLensProvider { + + provideCodeLenses(document: vscode.TextDocument) { + if (!Options.enableCodeLens) return undefined; + return ContextMapping.getDocumentContext(document).foldingRange.nodeValues; + } +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts index f7d516a..14be122 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -53,3 +53,44 @@ export function lineNumToRange(startLine: number, endLine = startLine) { const endPosition = new vscode.Position(endLine, Number.MAX_SAFE_INTEGER); return new vscode.Range(startPosition, endPosition); } + +/** + * Check whether character is an alphabet. + * + * @param charCode ASCII code of character. + */ +export function isAlpha(charCode: number) { + return charCode >= 97 && charCode <= 122 || charCode >= 65 && charCode <= 90; +} + +/** + * Check whether character is alphanumeric. + * + * @param charCode ASCII code of character. + */ +export function isAlnum(charCode: number) { + return isAlpha(charCode) || charCode >= 48 && charCode <= 57; +} + +/** + * Get corresponding HTML cross-reference name by node name. + * + * See section *HTML Cross-reference Node Name Expansion* in the Texinfo manual. + * + * TODO: Node name is not displayed verbatim, leading to wrong HTML xref when containing commands. + * Fix this when migrating to LSP. + * + * @param nodeName + */ +export function getNodeHtmlRef(nodeName: string) { + const result = nodeName.trim().split(/\s+/) + .map(word => word.split('') + .map(ch => { + const charCode = ch.charCodeAt(0); + return isAlnum(charCode) ? ch : '_00' + charCode.toString(16); + }) + .join('')) + .join('-'); + const firstCharCode = result.charCodeAt(0); + return isAlpha(firstCharCode) ? result : 'g_t_00' + firstCharCode.toString(16) + result.substring(1); +}