vscode/src/extension.ts
import path from 'path'
import child_process from 'child_process'
import { Readable } from 'stream'
import * as vscode from 'vscode'
const open = require('open')
const ourbigbook = require('ourbigbook')
const ourbigbook_nodejs_webpack_safe = require('ourbigbook/nodejs_webpack_safe')
const ourbigbook_nodejs_front = require('ourbigbook/nodejs_front')
const INSANE_HEADER_START_REGEXP = new RegExp(`^${ourbigbook.INSANE_HEADER_CHAR}+ `)
const MAX_IDS = 100
const OURBIGBOOK_LANGUAGE_ID = 'ourbigbook'
// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
/** Gets the path to the ourbigbook inside the extension.
* Returns the correct path, but that executable is not very portable because
* because of difficulties with native dependencies such as sqlite3.
* https://github.com/ourbigbook/ourbigbook/issues/318
*/
function getOurbigbookExecPath(): string {
return path.join(path.dirname(require.resolve('ourbigbook')), 'ourbigbook')
}
/**
* @param {vscode.ExtensionContext} context
*/
export async function activate(context: vscode.ExtensionContext) {
// State.
const channel = vscode.window.createOutputChannel('OurBigBook', 'ourbigbook')
let ourbigbookJsonDir: string|undefined
let sequelize: any
let dbProvider: any
let renderContext: any
// Sanity checks.
channel.appendLine('activate OutputChannel.appendLine')
channel.appendLine(`process.cwd=${process.cwd()}`)
channel.appendLine(`require.resolve('ourbigbook')=${require.resolve('ourbigbook')}`)
console.log('ourbigbook.activate log')
// Functions
function getOurbigbookJsonDir(): string | undefined {
const workspaceFolders = vscode.workspace.workspaceFolders
let curdir
const editor = vscode.window.activeTextEditor
if (editor) {
curdir = path.dirname(editor.document.fileName)
} else if (workspaceFolders) {
curdir = workspaceFolders[0].uri.path
}
channel.appendLine(`getOurbigbookJsonDir curdir=${curdir}`)
if (curdir) {
return ourbigbook_nodejs_webpack_safe.findOurbigbookJsonDir(curdir)
}
}
async function updateSequelize(oldOurbigbookJsonDir: string|undefined, funcname: string) {
if (ourbigbookJsonDir !== oldOurbigbookJsonDir) {
sequelize = await ourbigbook_nodejs_webpack_safe.createSequelize({
logging: (s: string) => channel.appendLine(`${funcname} sql=${s}`),
storage: path.join(
ourbigbookJsonDir as string,
ourbigbook_nodejs_webpack_safe.TMP_DIRNAME,
ourbigbook_nodejs_front.SQLITE_DB_BASENAME
),
})
dbProvider = new ourbigbook_nodejs_webpack_safe.SqlDbProvider(sequelize)
renderContext = ourbigbook.convertInitContext({
db_provider: dbProvider,
output_format: ourbigbook.OUTPUT_FORMAT_ID
})
}
}
async function buildAll(): Promise<number|undefined> {
// Also worked, but worse user experience.
// With task:
// - auto pops up terminal
// - user can Ctrl+Click to go to error message
//import child_process from 'child_process'
//import { Readable } from 'stream'
//function buildHandleStdout(stdout: Readable) {
// stdout.setEncoding('utf8')
// stdout.on('data', function(data: string) {
// for (const line of data.split('\n')) {
// if (line) {
// channel.appendLine('build: ' + line.replace(/(\n)$/m, ''))
// }
// }
// })
//}
//const p = child_process.spawn(getOurbigbookExecPath(), ['.'], { cwd: getOurbigbookJsonDir() })
//buildHandleStdout(p.stdout)
//buildHandleStdout(p.stderr)
// Save any unsaved changes.
const editor = vscode.window.activeTextEditor
if (editor) {
editor.document.save()
}
// build task
const quotingStyle: vscode.ShellQuoting = vscode.ShellQuoting.Strong
let myTaskCommand: vscode.ShellQuotedString = {
value: 'npx',
quoting: quotingStyle,
}
const args = ['ourbigbook', '.']
let myTaskArgs: vscode.ShellQuotedString[] = args.map((arg) => {
return { value: arg, quoting: quotingStyle }
})
let myTaskOptions: vscode.ShellExecutionOptions = {
cwd: getOurbigbookJsonDir(),
}
let shellExec: vscode.ShellExecution = new vscode.ShellExecution(
myTaskCommand,
myTaskArgs,
myTaskOptions
)
const taskName = 'build'
let myTask: vscode.Task = new vscode.Task(
{ type: "shell", group: "build", label: taskName },
vscode.TaskScope.Workspace,
taskName,
"makefile",
shellExec
)
myTask.presentationOptions.clear = true
myTask.presentationOptions.showReuseMessage = true
// This allows us to wait for the task to complete.
// https://stackoverflow.com/questions/61428928/how-to-await-a-build-task-in-a-vs-code-extension/61703141#61703141
const execution = await vscode.tasks.executeTask(myTask)
return new Promise(resolve => {
const disposable = vscode.tasks.onDidEndTaskProcess(e => {
if (e.execution === execution) {
disposable.dispose()
resolve(e.exitCode)
}
})
})
}
function openOutput() {
const editor = vscode.window.activeTextEditor
if (editor) {
const curFilepath = editor.document.fileName
const parse = path.parse(curFilepath)
channel.appendLine(`buildAndView: curFilepath=${curFilepath}`)
const ourbigbookJsonDir = getOurbigbookJsonDir() as string
if (parse.ext === `.${ourbigbook.OURBIGBOOK_EXT}`) {
const outpath = path.join(
ourbigbookJsonDir,
ourbigbook_nodejs_webpack_safe.TMP_DIRNAME,
ourbigbook.OUTPUT_FORMAT_HTML,
path.relative(parse.dir, ourbigbookJsonDir),
parse.name + '.' + ourbigbook.HTML_EXT
)
channel.appendLine(`openOutput: outpath=${outpath}`)
open(outpath)
} else {
vscode.window.showInformationMessage(`ourbigbook.openOutput: Don't know how to open the output for this file extension: ${curFilepath}`)
}
} else {
vscode.window.showInformationMessage(`ourbigbook.OurBigBook: no file or workspace is open`)
}
}
// Commands
context.subscriptions.push(
vscode.commands.registerCommand('ourbigbook.build', async function () {
return buildAll()
})
)
context.subscriptions.push(
vscode.commands.registerCommand('ourbigbook.buildAndView', async function () {
const ourbigbookJsonDirMaybe = getOurbigbookJsonDir()
if (typeof ourbigbookJsonDirMaybe === 'string') {
if (await buildAll() === 0 && vscode.window.activeTextEditor) {
openOutput()
}
}
})
)
context.subscriptions.push(
vscode.commands.registerCommand('ourbigbook.viewOutput', async function () {
openOutput()
})
)
context.subscriptions.push(
vscode.commands.registerCommand('ourbigbook.helloWorld', async function () {
console.log('ourbigbook.helloWorld console.log')
channel.appendLine('helloWorld OutputChannel.appendLine')
vscode.window.showInformationMessage(`Hello World from OurBigBook ts!`)
})
)
vscode.workspace.onDidSaveTextDocument((e) => {
if (e.languageId === OURBIGBOOK_LANGUAGE_ID) {
channel.appendLine(`onDidSaveTextDocument fileName=${e.fileName}`)
function buildHandleStdout(stdout: Readable) {
stdout.setEncoding('utf8')
stdout.on('data', function(data: string) {
for (const line of data.split('\n')) {
if (line) {
channel.appendLine(`onDidSaveTextDocument: ${e.fileName}: ` + line.replace(/(\n)$/m, ''))
}
}
})
}
const p = child_process.spawn('npx', ['ourbigbook', '--no-render', e.fileName], { cwd: getOurbigbookJsonDir() })
p.on('close', (code) => {
// Force outline refresh.
// https://stackoverflow.com/questions/58940136/vs-code-document-symbol-provider-incremental-refresh/78844031#78844031
if (code === 0) {
const editor = vscode.window.activeTextEditor
if (editor) {
const line = editor.document.lineAt(0)
const text = line.text
if (text.length) {
editor.edit(editBuilder => {
const c = line.range.end.character
editBuilder.delete(new vscode.Range(0, c-1, 0, c))
editBuilder.insert(new vscode.Position(0, c), text[c-1])
})
}
}
}
})
buildHandleStdout(p.stdout)
buildHandleStdout(p.stderr)
//const p = child_process.spawnSync('npx', ['ourbigbook', '--no-render', e.fileName], { cwd: getOurbigbookJsonDir() })
//console.log('onDidSaveTextDocument stdout:\n' + p.stdout)
//console.log('onDidSaveTextDocument stderr:\n' + p.stderr)
}
})
/* Ctrl + T */
class OurbigbookWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider {
/** The query only contains the string before the first space typed into
* the Ctrl+T bar... Related500:
* https://github.com/microsoft/vscode/issues/93645
* Anything after the first space is used by vscode as a further
* filter over something, not exactly symbol names either, so it is quite sad.
* We would need to implement our own custom search window to overcome this.
*/
async provideWorkspaceSymbols(query: string, token: vscode.CancellationToken) {
channel.appendLine(`provideWorkspaceSymbols query=${query}`)
let oldOurbigbookJsonDir = ourbigbookJsonDir
ourbigbookJsonDir = getOurbigbookJsonDir()
if (typeof(ourbigbookJsonDir) === 'string') {
channel.appendLine(`provideWorkspaceSymbols ourbigbookJsonDir=${ourbigbookJsonDir}`)
await updateSequelize(oldOurbigbookJsonDir, 'provideWorkspaceSymbols')
return Promise.all([
sequelize.models.Id.findAll({
attributes: { include: [ [sequelize.fn('LENGTH', sequelize.col('idid')), 'idid_length'], ], },
where: { idid: { [sequelize.Sequelize.Op.startsWith]: query } },
order: [[sequelize.literal('idid_length'), 'ASC'], ['idid', 'ASC']],
limit: MAX_IDS,
}),
sequelize.models.Id.findAll({
attributes: { include: [ [sequelize.fn('LENGTH', sequelize.col('idid')), 'idid_length'], ], },
where: { idid: { [sequelize.Sequelize.Op.like]: `_%${query}%` } },
order: [[sequelize.literal('idid_length'), 'ASC'], ['idid', 'ASC']],
limit: MAX_IDS,
}),
]).then(ids => ids.flat().map(id => {
const json = JSON.parse(id.ast_json)
const sourceLocation = json.source_location
return new vscode.SymbolInformation(
id.idid,
vscode.SymbolKind.Variable,
'',
new vscode.Location(
vscode.Uri.file(path.join(
// TODO why is as string needed here despite the above typeof check??
ourbigbookJsonDir as string
, sourceLocation.path)),
new vscode.Position(sourceLocation.line - 1, sourceLocation.column - 1),
)
)
}))
}
}
}
context.subscriptions.push(vscode.languages.registerWorkspaceSymbolProvider(new OurbigbookWorkspaceSymbolProvider()))
/* Ctrl + Shift + O and
* Ctrl + 3: outline: https://stackoverflow.com/questions/55846146/make-vs-code-parse-and-display-the-structure-of-a-new-language-to-the-outline-re
**/
class OurbigbooDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
/** The query only contains the string before the first space typed into
* the Ctrl+T bar... Related500:
* https://github.com/microsoft/vscode/issues/93645
* Anything after the first space is used by vscode as a further
* filter over something, not exactly symbol names either, so it is quite sad.
* We would need to implement our own custom search window to overcome this.
*/
async provideDocumentSymbols(document: vscode.TextDocument, token: vscode.CancellationToken) {
channel.appendLine(`provideDocumentSymbols document.fileName=${document.fileName}`)
const oldOurbigbookJsonDir = ourbigbookJsonDir
ourbigbookJsonDir = getOurbigbookJsonDir()
if (typeof(ourbigbookJsonDir) === 'string') {
const relpath = path.relative(ourbigbookJsonDir, document.fileName)
if (ourbigbookJsonDir !== oldOurbigbookJsonDir) {
await updateSequelize(oldOurbigbookJsonDir, 'provideDocumentSymbols')
}
//const ids = await sequelize.models.Id.findAll({
// where: { macro_name: ourbigbook.Macro.HEADER_MACRO_NAME },
// include: [
// {
// model: sequelize.models.File,
// as: 'idDefinedAt',
// where: { path: relpath },
// },
// //{
// // model: sequelize.models.Ref,
// // as: 'from',
// // where: { type: sequelize.models.Ref.Types[ourbigbook.REFS_TABLE_PARENT] },
// //},
// ],
// // TODO ascending line number, column number here.
// //order: [['idid', 'ASC']],
//})
//const jsons: any[] = ids.map((id: any) => JSON.parse(id.ast_json)).sort((a: any, b: any) => {
// const x = a.source_location.line
// const y = b.source_location.line
// return ((x < y) ? -1 : ((x > y) ? 1 : 0))
//})
//const ret = []
//for (var i = 0; i < jsons.length; i++) {
// const json = jsons[i]
// const json2 = jsons[i + 1]
// let endLine, endColumn
// if (json2) {
// endLine = json2.source_location.line - 1
// endColumn = json2.source_location.column - 1
// } else {
// endLine = document.lineCount
// endColumn = 0
// }
// const range = new vscode.Range(
// json.source_location.line - 1,
// json.source_location.column - 1,
// endLine,
// endColumn,
// )
// let line = document.lineAt(json.source_location.line - 1).text
// if (line.startsWith(INSANE_HEADER_START)) {
// line = line.substring(INSANE_HEADER_START.length)
// }
// ret.push(new vscode.DocumentSymbol(
// line,
// '',
// vscode.SymbolKind.Function,
// range,
// range,
// ))
//}
//return ret
const file = await sequelize.models.File.findOne({
where: { path: relpath },
include: [{
model: sequelize.models.Id,
as: 'toplevelId'
}]
})
const fetchHeaderTreeIdsRows = await dbProvider.fetch_header_tree_ids(
[file.toplevelId.idid], { crossFileBoundaries: false })
const toplevelIdJson = JSON.parse(file.toplevelId.ast_json)
const toplevelIdJsonSourceLocation = toplevelIdJson.source_location
const toplevelHeaderTreeNode = new ourbigbook.HeaderTreeNode()
dbProvider.build_header_tree(fetchHeaderTreeIdsRows, {
context: renderContext,
// Otherwise we can't know h2 indices.
toplevelHeaderTreeNode,
})
function getName(lineNum: number) {
//channel.appendLine(`provideDocumentSymbols.getName lineNum=${lineNum} document.lineAt(lineNum)=${document.lineAt(lineNum).text}`)
const text = document.lineAt(lineNum).text
const ret = text.replace(INSANE_HEADER_START_REGEXP, '')
if (ret !== text) {
return ret
} else {
return '[TOC OUTDATED, TRY SAVING THE FILE AGAIN WITH CTRL + S]'
}
}
// Toplevel not returned from the tree fetch, so we manually add it here.
const toplevelDocumentSymbol = new vscode.DocumentSymbol(
getName(toplevelIdJsonSourceLocation.line - 1),
'',
vscode.SymbolKind.Function,
new vscode.Range(
toplevelIdJsonSourceLocation.line - 1,
toplevelIdJsonSourceLocation.column - 1,
document.lineCount - 1,
0
),
new vscode.Range(
toplevelIdJsonSourceLocation.line - 1,
toplevelIdJsonSourceLocation.column - 1,
toplevelIdJsonSourceLocation.line - 1,
document.lineAt(toplevelIdJsonSourceLocation.column - 1).text.length
),
)
const ret = [toplevelDocumentSymbol]
toplevelHeaderTreeNode.documentSymbol = toplevelDocumentSymbol
const todoVisit = []
for (let i = toplevelHeaderTreeNode.children.length - 1; i >= 0; i--) {
todoVisit.push(toplevelHeaderTreeNode.children[i])
toplevelHeaderTreeNode.children[i].nextSibling = toplevelHeaderTreeNode.children[i+1]
}
while (todoVisit.length > 0) {
const treeNode = todoVisit.pop()
const ast = treeNode.ast
//channel.appendLine(`provideDocumentSymbols treeNode.ast.id=${treeNode.ast.id}`)
let endLine, endColumn
const parentTreeNode = treeNode.parent_ast
const nextSibling = parentTreeNode.children[treeNode.index + 1]
if (nextSibling) {
endLine = nextSibling.ast.source_location.line - 2
endColumn = nextSibling.ast.source_location.column
} else {
const nextSiblingParent = parentTreeNode.nextSibling
if (nextSiblingParent) {
endLine = nextSiblingParent.ast.source_location.line - 2
endColumn = nextSiblingParent.ast.source_location.column
} else {
endLine = document.lineCount - 1
endColumn = 0
}
}
const documentSymbol = new vscode.DocumentSymbol(
getName(ast.source_location.line - 1),
'',
vscode.SymbolKind.Function,
new vscode.Range(
ast.source_location.line - 1,
ast.source_location.column - 1,
endLine,
endColumn,
),
new vscode.Range(
ast.source_location.line - 1,
ast.source_location.column - 1,
ast.source_location.line - 1,
document.lineAt(ast.source_location.line - 1).text.length,
),
)
parentTreeNode.documentSymbol.children.push(documentSymbol)
treeNode.documentSymbol = documentSymbol
for (let i = treeNode.children.length - 1; i >= 0; i--) {
todoVisit.push(treeNode.children[i])
}
}
return ret
}
return []
}
}
context.subscriptions.push(vscode.languages.registerDocumentSymbolProvider(
{ scheme: 'file', language: OURBIGBOOK_LANGUAGE_ID },
new OurbigbooDocumentSymbolProvider()
))
/* Autocomplete */
class OurbigbookCompletionItemProvider implements vscode.CompletionItemProvider {
/** The query only contains the string before the first space typed into
* the Ctrl+T bar... Related
* https://github.com/microsoft/vscode/issues/93645
* Anything after the first space is used by vscode as a further
* filter over something, not exactly symbol names either, so it is quite sad.
* We would need to implement our own custom search window to overcome this.
*/
async provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
context: vscode.CompletionContext
) {
const col = position.character
channel.appendLine(`provideCompletionItems position=${position.line}:${col}`)
channel.appendLine(`provideCompletionItems context={triggerCharacter=${context.triggerCharacter}, triggerKind=${context.triggerKind}`)
const lineToCursor = document.lineAt(position.line).text.substring(0, col)
const matches = [...lineToCursor.matchAll(/(?<=<)[^>]*$|(?<=\{(parent|tag)=)[^}]*$/g)]
if (matches.length) {
const lastMatch = matches[matches.length - 1]
const queryRaw = lineToCursor.substring(lastMatch.index, col)
const query = ourbigbook.titleToId(queryRaw)
if (query) {
const c0 = queryRaw[0]
const queryIsLower = c0.toLowerCase() === c0
let oldOurbigbookJsonDir = ourbigbookJsonDir
ourbigbookJsonDir = getOurbigbookJsonDir()
if (typeof(ourbigbookJsonDir) === 'string') {
if (ourbigbookJsonDir !== oldOurbigbookJsonDir) {
await updateSequelize(oldOurbigbookJsonDir, 'provideCompletionItems')
}
async function createCompletionItem(ids: any[], atStart: boolean) {
const ret = []
for (const id of ids) {
// This slightly duplicates <> ourbigbook output type conversion,
// but it was a bit different and much simpler. Let's see.
const ast = ourbigbook.AstNode.fromJSON(id.ast_json, renderContext)
const macro = renderContext.macros[ast.macro_name];
const titleArg = macro.options.get_title_arg(ast, renderContext);
let label = ourbigbook.renderArg(titleArg, renderContext)
const idPrefix = macro.options.id_prefix
if (idPrefix) {
label = `${idPrefix} ${ourbigbook.capitalizeFirstLetter(label)}`
} else {
if (atStart) {
if (!(
ast.validation_output.c &&
ast.validation_output.c.boolean
)) {
if (queryIsLower) {
label = ourbigbook.decapitalizeFirstLetter(label)
} else {
label = ourbigbook.capitalizeFirstLetter(label)
}
}
}
}
ret.push(new vscode.CompletionItem(label))
}
return ret
}
return new vscode.CompletionList(
[
...(await sequelize.models.Id.findAll({
attributes: { include: [ [sequelize.fn('LENGTH', sequelize.col('idid')), 'idid_length'], ], },
where: { idid: { [sequelize.Sequelize.Op.startsWith]: query } },
// No matter what we set here, vscode then re-sorts it on the UI it is so annoying!
// https://github.com/microsoft/monaco-editor/issues/1077
order: [[sequelize.literal('idid_length'), 'ASC'], ['idid', 'ASC']],
limit: MAX_IDS,
}).then((ids:any) => createCompletionItem(ids, true))),
...await sequelize.models.Id.findAll({
attributes: { include: [ [sequelize.fn('LENGTH', sequelize.col('idid')), 'idid_length'], ], },
where: { idid: { [sequelize.Sequelize.Op.like]: `_%${query}%` } },
order: [[sequelize.literal('idid_length'), 'ASC'], ['idid', 'ASC']],
limit: MAX_IDS,
}).then((ids:any) => createCompletionItem(ids, false)),
],
// This way it keeps triggering we type more characters.
true,
)
}
}
}
return []
}
}
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{ scheme: 'file', language: OURBIGBOOK_LANGUAGE_ID },
new OurbigbookCompletionItemProvider(),
// TODO what does this give us?
'<',
)
)
/* Ctrl + click to jump to definition */
class OurbigbookDefinitionProvider implements vscode.DefinitionProvider {
async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
) {
const col = position.character
channel.appendLine(`provideDefinition position=${position.line}:${col}`)
const line = document.lineAt(position.line).text
let find
for (const match of line.matchAll(/(<|\{(parent|tag)=)(.*?)(>|})/g)) {
if (col >= match.index && col <= match.index + match[0].length) {
find = match[3]
}
}
if (!find) {
// Search for insane #topic links without <>.
for (const match of line.matchAll(/#[^\[\]{} \n]+/g)) {
if (col >= match.index && col <= match.index + match[0].length) {
find = match[0]
}
}
}
channel.appendLine(`provideDefinition find=${find}`)
const ret = []
if (find) {
const textId = ourbigbook.titleToId(find)
if (find[0] === ourbigbook.INSANE_TOPIC_CHAR) {
open(`https://${ourbigbook.OURBIGBOOK_DEFAULT_HOST}${ourbigbook.URL_SEP}${ourbigbook.WEB_TOPIC_PATH}${ourbigbook.URL_SEP}${ourbigbook.pluralizeWrap(textId, 1)}`)
} else {
let oldOurbigbookJsonDir = ourbigbookJsonDir
ourbigbookJsonDir = getOurbigbookJsonDir()
if (typeof(ourbigbookJsonDir) === 'string') {
await updateSequelize(oldOurbigbookJsonDir, 'provideWorkspaceSymbols')
channel.appendLine(`provideWorkspaceSymbols ourbigbookJsonDir=${ourbigbookJsonDir} textId=${textId}`)
let id = await sequelize.models.Id.findOne({
where: { idid: textId },
})
if (!id) {
id = await sequelize.models.Id.findOne({
where: { idid: ourbigbook.pluralizeWrap(textId, 1) },
})
}
if (id) {
const json = JSON.parse(id.ast_json)
const sourceLocation = json.source_location
ret.push(new vscode.Location(
vscode.Uri.file(path.join(ourbigbookJsonDir, sourceLocation.path)),
new vscode.Range(sourceLocation.line-1, sourceLocation.column-1, sourceLocation.line-1, sourceLocation.column-1),
))
}
}
}
}
return ret
}
}
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
{ scheme: 'file', language: OURBIGBOOK_LANGUAGE_ID },
new OurbigbookDefinitionProvider(),
)
)
}
export function deactivate() { }