Re-implement metadata API on top of tsdoc output

This commit is contained in:
glacambre 2026-05-12 18:17:38 +02:00 committed by Oliver Blanthorn
parent 77fc67ca0c
commit 2bd1f5502b
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
31 changed files with 434 additions and 894 deletions

3
.gitignore vendored
View file

@ -10,9 +10,8 @@ native/native_main
native_main.spec
.wine-pyinstaller
tags
compiler/*.js
compiler/**/*.js
.*.generated.ts
.*.generated.json
.tmp/
.DS_Store
.build_cache/

View file

@ -1,274 +0,0 @@
import * as ts from "typescript"
import * as fs from "fs"
import * as commandLineArgs from "command-line-args"
import * as AllTypes from "./types/AllTypes"
import * as AllMetadata from "./metadata/AllMetadata"
export function toSimpleType(typeNode) {
switch (typeNode.kind) {
case ts.SyntaxKind.VoidKeyword:
return new AllTypes.VoidType()
// IndexedAccessTypes are things like `fn<T keyof Class>(x: T, y: Class[T])`
// This doesn't seem to be easy to deal with so let's kludge it for now
case ts.SyntaxKind.IndexedAccessType:
// Unknown is just like any, but slightly stricter
case ts.SyntaxKind.UnknownKeyword:
case ts.SyntaxKind.AnyKeyword:
return new AllTypes.AnyType()
case ts.SyntaxKind.BooleanKeyword:
return new AllTypes.BooleanType()
case ts.SyntaxKind.NumberKeyword:
return new AllTypes.NumberType()
case ts.SyntaxKind.ObjectKeyword:
return new AllTypes.ObjectType()
case ts.SyntaxKind.StringKeyword:
return new AllTypes.StringType()
case ts.SyntaxKind.Parameter:
let n = toSimpleType(typeNode.type)
n.name = typeNode.name.original.escapedText
n.isDotDotDot = !!typeNode.dotDotDotToken
n.isQuestion = !!typeNode.questionToken
return n
case ts.SyntaxKind.TypeReference:
if (!typeNode.typeArguments) {
// If there are no typeArguments, this is not a parametric type and we can return the type directly
try {
return toSimpleType(
typeNode.typeName.symbol.declarations[0].type,
)
} catch (e) {
// Fall back to what you'd do with typeArguments
}
}
let args = typeNode.typeArguments
? typeNode.typeArguments.map(t =>
toSimpleType(typeNode.typeArguments[0]),
)
: []
return new AllTypes.TypeReferenceType(
typeNode.typeName.escapedText,
args,
)
case ts.SyntaxKind.FunctionType:
// generics = (typeNode.typeParameters || []).map(p => new AllTypes.SimpleType(p))
return new AllTypes.FunctionType(
typeNode.parameters.map(p => toSimpleType(p)),
toSimpleType(typeNode.type),
)
case ts.SyntaxKind.TypeLiteral:
let members = typeNode.members
.map(member => {
if (member.kind == ts.SyntaxKind.IndexSignature) {
// Something like this: { [str: string]: string[] }
return ["", toSimpleType(member.type)]
}
// Very fun feature: when you have an object literal with >20 members, typescript will decide to replace some of them with a "... X more ..." node that obviously doesn't have a corresponding symbol, hence this check and the filter after the map
if (member.name.symbol)
return [
member.name.symbol.escapedName,
toSimpleType(member.type),
]
})
.filter(m => m)
return new AllTypes.ObjectType(new Map(members))
case ts.SyntaxKind.ArrayType:
return new AllTypes.ArrayType(toSimpleType(typeNode.elementType))
case ts.SyntaxKind.TupleType:
return new AllTypes.TupleType(
typeNode.elementTypes.map(t => toSimpleType(t)),
)
case ts.SyntaxKind.UnionType:
return new AllTypes.UnionType(
typeNode.types.map(t => toSimpleType(t)),
)
break
case ts.SyntaxKind.LiteralType:
return new AllTypes.LiteralTypeType(typeNode.literal.text)
break
default:
console.log(typeNode)
throw new Error(`Unhandled kind (${typeNode.kind}) for ${typeNode}`)
}
}
/** True if node is visible outside its file, false otherwise */
function isNodeExported(node: ts.Node): boolean {
return (
(ts.getCombinedModifierFlags(<ts.Declaration>node) &
ts.ModifierFlags.Export) !==
0 ||
(!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile)
)
}
/** True if node is marked as @hidden in its documentation */
function isNodeHidden(sourceFile, node): boolean {
return (
sourceFile &&
node.jsDoc &&
!!node.jsDoc.find(
doc =>
sourceFile.text.slice(doc.pos, doc.end).search("@hidden") != -1,
)
)
}
function visit(
checker: any,
sourceFile: any,
file: AllMetadata.FileMetadata,
node: any,
) {
let symbol = checker.getSymbolAtLocation(node.name)
if (symbol && isNodeExported(node)) {
let nodeName = symbol.escapedName
switch (node.kind) {
case ts.SyntaxKind.FunctionDeclaration:
// Grab the doc, default to empty string
let doc =
ts.displayPartsToString(symbol.getDocumentationComment()) ||
""
// Grab the type
let ttype = checker.getTypeOfSymbolAtLocation(
symbol,
symbol.valueDeclaration!,
)
// If the function has a type, try to convert it, if it doesn't, default to any
let t = ttype
? toSimpleType(checker.typeToTypeNode(ttype))
: new AllTypes.AnyType()
file.setFunction(
nodeName,
new AllMetadata.SymbolMetadata(
doc,
t,
isNodeHidden(sourceFile, node),
),
)
return
case ts.SyntaxKind.ClassDeclaration:
let clazz = file.getClass(nodeName)
if (!clazz) {
clazz = new AllMetadata.ClassMetadata()
file.setClass(nodeName, clazz)
}
symbol.members.forEach((sym, name, map) => {
// Can't get doc/type from these special functions
// Or at least, it requires work that might not be needed for now
if (["__constructor", "get", "set"].includes(name)) return
// Grab the doc, default to empty string
let doc =
ts.displayPartsToString(
sym.getDocumentationComment(),
) || ""
// Grab the type
let ttype = checker.getTypeOfSymbolAtLocation(
sym,
sym.valueDeclaration!,
)
// If the function has a type, try to convert it, if it doesn't, default to any
let t = ttype
? toSimpleType(checker.typeToTypeNode(ttype))
: new AllTypes.AnyType()
clazz.setMember(
name,
new AllMetadata.SymbolMetadata(
doc,
t,
isNodeHidden(sourceFile, node),
),
)
})
return
// Other declaration syntaxkinds:
// case ts.SyntaxKind.VariableDeclaration:
// case ts.SyntaxKind.VariableDeclarationList:
// case ts.SyntaxKind.PropertyDeclaration:
// case ts.SyntaxKind.MethodDeclaration:
// case ts.SyntaxKind.EndOfDeclarationMarker:
// case ts.SyntaxKind.MergeDeclarationMarker:
// case ts.SyntaxKind.MissingDeclaration:
// case ts.SyntaxKind.ClassExpression:
// case ts.SyntaxKind.InterfaceDeclaration:
// case ts.SyntaxKind.TypeAliasDeclaration:
// case ts.SyntaxKind.EnumDeclaration:
// case ts.SyntaxKind.ModuleDeclaration:
// case ts.SyntaxKind.ImportEqualsDeclaration:
// case ts.SyntaxKind.ImportDeclaration:
// case ts.SyntaxKind.NamespaceExportDeclaration:
// case ts.SyntaxKind.ExportDeclaration:
// case ts.SyntaxKind.Constructor:
}
}
ts.forEachChild(node, node => visit(checker, sourceFile, file, node))
}
function generateMetadata(
out: string,
themedir: string,
fileNames: string[],
): void {
/* Parse Tridactyl */
let program = ts.createProgram(fileNames, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
})
let metadata = new AllMetadata.ProgramMetadata()
for (const sourceFile of program.getSourceFiles()) {
let name = (fileNames as any).find(name =>
sourceFile.fileName.match(name),
)
if (name) {
let file = metadata.getFile(name)
if (!file) {
file = new AllMetadata.FileMetadata()
metadata.setFile(name, file)
}
visit(program.getTypeChecker(), sourceFile, file, sourceFile)
}
}
// We need to specify Type itself because it won't exist in AllTypes.js since it's an interface
let imports =
`import { Type } from "../compiler/types/AllTypes"\n` +
`import {${Object.keys(AllTypes).join(
", ",
)}} from "../compiler/types/AllTypes"\n` +
`import {${Object.keys(AllMetadata).join(
", ",
)}} from "../compiler/metadata/AllMetadata"\n`
let metadataString =
imports + `\nexport let everything = ${metadata.toConstructor()}\n`
if (themedir) {
metadataString += `\nexport let staticThemes = ${JSON.stringify(
fs.readdirSync(themedir),
)}\n`
}
// print out the doc
fs.writeFileSync(out, metadataString)
return
}
let opts = commandLineArgs([
{ name: "out", type: String },
{ name: "themeDir", type: String },
{ name: "src", type: String, multiple: true, defaultOption: true },
])
if (!opts.out || opts.src.length < 1)
throw new Error(
"Argument syntax: --out outfile [--src] file1.ts [file2.ts ...]",
)
generateMetadata(opts.out, opts.themeDir, opts.src)

View file

@ -1,4 +0,0 @@
export { SymbolMetadata } from "./SymbolMetadata"
export { ClassMetadata } from "./ClassMetadata"
export { FileMetadata } from "./FileMetadata"
export { ProgramMetadata } from "./ProgramMetadata"

View file

@ -1,33 +0,0 @@
import { SymbolMetadata } from "./SymbolMetadata"
export class ClassMetadata {
constructor(
public members: Map<string, SymbolMetadata> = new Map<
string,
SymbolMetadata
>(),
) {}
public setMember(name: string, s: SymbolMetadata) {
this.members.set(name, s)
}
public getMember(name: string) {
return this.members.get(name)
}
public getMembers() {
return this.members.keys()
}
public toConstructor() {
return (
`new ClassMetadata(new Map<string, SymbolMetadata>([` +
Array.from(this.members.entries())
.map(([n, m]) => `[${JSON.stringify(n)}, ${m.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -1,57 +0,0 @@
import { ClassMetadata } from "./ClassMetadata"
import { SymbolMetadata } from "./SymbolMetadata"
export class FileMetadata {
constructor(
public classes: Map<string, ClassMetadata> = new Map<
string,
ClassMetadata
>(),
public functions: Map<string, SymbolMetadata> = new Map<
string,
SymbolMetadata
>(),
) {}
public setClass(name: string, c: ClassMetadata) {
this.classes.set(name, c)
}
public getClass(name: string) {
return this.classes.get(name)
}
public getClasses() {
return Array.from(this.classes.keys())
}
public setFunction(name: string, f: SymbolMetadata) {
this.functions.set(name, f)
}
public getFunction(name: string) {
return this.functions.get(name)
}
public getFunctions() {
return Array.from(this.functions.entries())
}
public getFunctionNames() {
return Array.from(this.functions.keys())
}
public toConstructor() {
return (
`new FileMetadata(new Map<string, ClassMetadata>([` +
Array.from(this.classes.entries())
.map(([n, c]) => `[${JSON.stringify(n)}, ${c.toConstructor()}]`)
.join(",\n") +
`]), new Map<string, SymbolMetadata>([` +
Array.from(this.functions.entries())
.map(([n, f]) => `[${JSON.stringify(n)}, ${f.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -1,28 +0,0 @@
import { FileMetadata } from "./FileMetadata"
export class ProgramMetadata {
constructor(
public files: Map<string, FileMetadata> = new Map<
string,
FileMetadata
>(),
) {}
public setFile(name: string, file: FileMetadata) {
this.files.set(name, file)
}
public getFile(name: string) {
return this.files.get(name)
}
public toConstructor() {
return (
`new ProgramMetadata(new Map<string, FileMetadata>([` +
Array.from(this.files.entries())
.map(([n, f]) => `[${JSON.stringify(n)}, ${f.toConstructor()}]`)
.join(",\n") +
`]))`
)
}
}

View file

@ -1,11 +0,0 @@
import { Type } from "../types/AllTypes"
export class SymbolMetadata {
constructor(public doc: string, public type: Type, public hidden = false) {}
public toConstructor() {
return `new SymbolMetadata(${JSON.stringify(
this.doc,
)}, ${this.type.toConstructor()}, ${this.hidden})`
}
}

View file

@ -1,13 +0,0 @@
export { Type } from "./Type"
export { AnyType } from "./AnyType"
export { BooleanType } from "./BooleanType"
export { FunctionType } from "./FunctionType"
export { NumberType } from "./NumberType"
export { ObjectType } from "./ObjectType"
export { StringType } from "./StringType"
export { TypeReferenceType } from "./TypeReferenceType"
export { VoidType } from "./VoidType"
export { ArrayType } from "./ArrayType"
export { LiteralTypeType } from "./LiteralTypeType"
export { TupleType } from "./TupleType"
export { UnionType } from "./UnionType"

View file

@ -1,19 +0,0 @@
import { Type } from "./Type"
export class AnyType implements Type {
public kind = "any"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new AnyType(${!this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
return argument
}
}

View file

@ -1,29 +0,0 @@
import { Type } from "./Type"
export class ArrayType implements Type {
public kind = "array"
constructor(public elemType: Type, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new ArrayType(${this.elemType.toConstructor()}, ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return `${this.elemType.toString()}[]`
}
public convert(argument) {
if (!Array.isArray(argument)) {
try {
argument = JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert ${argument} to array:`)
}
if (!Array.isArray(argument)) {
throw new Error(`Can't convert ${argument} to array:`)
}
}
return argument.map(v => this.elemType.convert(v))
}
}

View file

@ -1,24 +0,0 @@
import { Type } from "./Type"
export class BooleanType implements Type {
public kind = "boolean"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new BooleanType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
if (argument === "true") {
return true
} else if (argument === "false") {
return false
}
throw new Error("Can't convert ${argument} to boolean")
}
}

View file

@ -1,28 +0,0 @@
import { Type } from "./Type"
export class FunctionType implements Type {
public kind = "function"
constructor(public args: Type[], public ret: Type, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new FunctionType([` +
// Convert every argument type to its string constructor representation
this.args.map(cur => cur.toConstructor()) +
`], ${this.ret.toConstructor()}, ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `(${this.args.map(a => a.toString()).join(", ")}) => ${this.ret.toString()}`
}
public convert(argument) {
// Possible strategies:
// - eval()
// - window[argument]
// - tri.excmds[argument]
throw new Error(`Conversion to function not implemented: ${argument}`)
}
}

View file

@ -1,26 +0,0 @@
import { Type } from "./Type"
export class LiteralTypeType implements Type {
public kind = "LiteralType"
constructor(public value: string, public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new LiteralTypeType(${JSON.stringify(this.value)}, ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return JSON.stringify(this.value)
}
public convert(argument) {
if (argument === this.value) {
return argument
}
throw new Error(
`Argument does not match expected value (${
this.value
}): ${argument}`,
)
}
}

View file

@ -1,23 +0,0 @@
import { Type } from "./Type"
export class NumberType implements Type {
public kind = "number"
public constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new NumberType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
const n = parseFloat(argument)
if (!Number.isNaN(n)) {
return n
}
throw new Error(`Can't convert to number: ${argument}`)
}
}

View file

@ -1,43 +0,0 @@
import { Type } from "./Type"
export class ObjectType implements Type {
public kind = "object"
// Note: a map that has an empty key ("") uses the corresponding type as default type
constructor(public members: Map<string, Type> = new Map<string, Type>(), public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new ObjectType(new Map<string, Type>([` +
Array.from(this.members.entries()).map(([n, m]) => `[${JSON.stringify(n)}, ${m.toConstructor()}]`)
.join(", ") +
`]), ${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convertMember(memberName: string[], memberValue: string) {
let type = this.members.get(memberName[0])
if (!type) {
// No type, try to get the default type
type = this.members.get("")
if (!type) {
// No info for this member and no default type, anything goes
return memberValue
}
}
if (type.kind === "object") {
return (type as ObjectType).convertMember(memberName.slice(1), memberValue)
}
return type.convert(memberValue)
}
public convert(argument) {
try {
return JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert to object: ${argument}`)
}
}
}

View file

@ -1,22 +0,0 @@
import { Type } from "./Type"
export class StringType implements Type {
public kind = "string"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new StringType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
if (typeof argument === "string") {
return argument
}
throw new Error(`Can't convert to string: ${argument}`)
}
}

View file

@ -1,39 +0,0 @@
import { Type } from "./Type"
export class TupleType implements Type {
public kind = "tuple"
constructor(public elemTypes: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new TupleType([` +
// Convert every element type to its constructor representation
this.elemTypes.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `[${this.elemTypes.map(e => e.toString()).join(", ")}]`
}
public convert(argument) {
if (!Array.isArray(argument)) {
try {
argument = JSON.parse(argument)
} catch (e) {
throw new Error(`Can't convert to tuple: ${argument}`)
}
if (!Array.isArray(argument)) {
throw new Error(`Can't convert to tuple: ${argument}`)
}
}
if (argument.length !== this.elemTypes.length) {
throw new Error(
`Error converting tuple: number of elements and type mismatch ${argument}`,
)
}
return argument.map((v, i) => this.elemTypes[i].convert(v))
}
}

View file

@ -1,12 +0,0 @@
export interface Type {
// Only available on argument types
name?: string
isDotDotDot?: boolean
isQuestion?: boolean
// available everywhere
kind: string
toConstructor(): string
toString(): string
convert(argument: string): any
}

View file

@ -1,22 +0,0 @@
import { Type } from "./Type"
export class TypeReferenceType implements Type {
public constructor(public kind: string, public args: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new TypeReferenceType(${JSON.stringify(this.kind)}, [` +
// Turn every type argument into its constructor representation
this.args.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return `${this.kind}<${this.args.map(a => a.toString()).join(", ")}>`
}
public convert(argument) {
throw new Error("Conversion of simple type references not implemented.")
}
}

View file

@ -1,29 +0,0 @@
import { Type } from "./Type"
export class UnionType implements Type {
public kind = "union"
constructor(public types: Type[], public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return (
`new UnionType([` +
// Convert every type to its string constructor representation
this.types.map(cur => cur.toConstructor()).join(",\n") +
`], ${this.isDotDotDot}, ${this.isQuestion})`
)
}
public toString() {
return this.types.map(t => t.toString()).join(" | ")
}
public convert(argument) {
for (const t of this.types) {
try {
return t.convert(argument)
} catch (e) {}
}
throw new Error(`Can't convert "${argument}" to any of: ${this.types}`)
}
}

View file

@ -1,19 +0,0 @@
import { Type } from "./Type"
export class VoidType implements Type {
public kind = "void"
constructor(public isDotDotDot = false, public isQuestion = false) {}
public toConstructor() {
return `new VoidType(${this.isDotDotDot}, ${this.isQuestion})`
}
public toString() {
return this.kind
}
public convert(argument) {
return null
}
}

View file

@ -57,13 +57,15 @@ if [ "$QUICK_BUILD" != "1" ]; then
"$(yarn bin)/nearleyc" src/grammars/bracketexpr.ne > \
src/grammars/.bracketexpr.generated.ts
# It's important to generate the metadata before the documentation because
# missing imports might break documentation generation on clean builds
"$(yarn bin)/tsc" compiler/gen_metadata.ts -m commonjs --target es2017 \
&& node compiler/gen_metadata.js \
--out src/.metadata.generated.ts \
--themeDir src/static/themes \
src/excmds.ts src/lib/config.ts
# Generate runtime metadata via typedoc. src/lib/metadata.ts loads the
# JSON and exposes the ProgramMetadata/FileMetadata/Type surface used at
# runtime; the .metadata.generated.ts shim below routes the public
# @src/.metadata.generated import path to that loader.
"$(yarn bin)/typedoc" --json src/.metadata.generated.json --mode file \
--exclude 'src/.excmds_*.generated.ts' --ignoreCompilerErrors \
src/excmds.ts src/lib/config.ts
node -e "var fs=require('fs');fs.writeFileSync('src/.themes.generated.json',JSON.stringify(fs.readdirSync('src/static/themes')))"
printf 'export * from "./lib/metadata"\n' > src/.metadata.generated.ts
scripts/newtab.md.sh
scripts/make_tutorial.sh

View file

@ -1,13 +1,24 @@
import * as Completions from "@src/completions"
import * as Metadata from "@src/.metadata.generated"
import {
excmdsFunctions,
defaultConfigMembers,
getDoc,
memberDoc,
} from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
class AproposCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
class AproposCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse
{
public fuseKeys = []
constructor(public name: string, doc: string, flag: string) {
constructor(
public name: string,
doc: string,
flag: string,
) {
super()
this.value = `${flag} ${name}`
this.html = html`<tr class="AproposCompletionOption option">
@ -42,20 +53,13 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
return
}
const file = Metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const excmds = Metadata.everything.getFile("src/excmds.ts")
const fns = excmds.getFunctions()
const settings = config.get()
const exaliases = settings.exaliases
const bindings = settings.nmaps
if (
fns === undefined ||
exaliases === undefined ||
bindings === undefined
) {
if (exaliases === undefined || bindings === undefined) {
return
}
const fns = Object.entries(excmdsFunctions)
const flags = {
"-a": (options, query) =>
@ -65,16 +69,14 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
(
alias +
aliases.expandExstr(alias) +
excmds.getFunction(aliases.expandExstr(alias))
excmdsFunctions[aliases.expandExstr(alias)]
)
.toLowerCase()
.includes(query),
)
.map(alias => {
const cmd = aliases.expandExstr(alias)
const doc =
(excmds.getFunction(cmd) || ({} as any)).doc ||
""
const doc = getDoc(excmdsFunctions[cmd])
return new AproposCompletionOption(
alias,
`Alias for \`${cmd}\`. ${doc}`,
@ -102,16 +104,14 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
"-e": (options, query) =>
options.concat(
fns
.filter(
([name, fn]) =>
!fn.hidden &&
(name + fn.doc).toLowerCase().includes(query),
.filter(([name, fn]) =>
(name + getDoc(fn)).toLowerCase().includes(query),
)
.map(
([name, fn]) =>
new AproposCompletionOption(
name,
`Excmd. ${fn.doc}`,
`Excmd. ${getDoc(fn)}`,
"-e",
),
),
@ -120,16 +120,12 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
options.concat(
Object.keys(settings)
.filter(x =>
(x + default_config.getMember(x)?.doc)
(x + memberDoc(defaultConfigMembers[x]))
.toLowerCase()
.includes(query),
)
.map(setting => {
const member = default_config.getMember(setting)
let doc = ""
if (member !== undefined) {
doc = member.doc
}
const doc = memberDoc(defaultConfigMembers[setting])
return new AproposCompletionOption(
setting,
`Setting. ${doc}`,

View file

@ -1,6 +1,6 @@
import * as Completions from "@src/completions"
import * as ExcmdCompletions from "@src/completions/Excmd"
import * as Metadata from "@src/.metadata.generated"
import { excmdsFunctions, getDoc } from "@src/.metadata.generated"
import * as config from "@src/lib/config"
import * as aliases from "@src/lib/aliases"
@ -65,21 +65,15 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
return
}
const excmds = Metadata.everything.getFile("src/excmds.ts")
if (!excmds) return
const fns = excmds.getFunctions()
// Add all excmds that start with exstr and that tridactyl has metadata about to completions
this.options = this.scoreOptions(
fns
.filter(
([name, fn]) => !fn.hidden && name.startsWith(end_exstr),
)
Object.entries(excmdsFunctions)
.filter(([name]) => name.startsWith(end_exstr))
.map(
([name, fn]) =>
new ExcmdCompletions.ExcmdCompletionOption(
name,
fn.doc,
getDoc(fn),
),
),
)
@ -90,12 +84,12 @@ export class CompositeCompletionSource extends Completions.CompletionSourceFuse
)
for (const alias of exaliases) {
const cmd = aliases.expandExstr(alias)
const fn = excmds.getFunction(cmd)
const fn = excmdsFunctions[cmd]
if (fn) {
this.options.push(
new ExcmdCompletions.ExcmdCompletionOption(
alias,
`Alias for \`${cmd}\`. ${fn.doc}`,
`Alias for \`${cmd}\`. ${getDoc(fn)}`,
),
)
} else {

View file

@ -1,12 +1,17 @@
import * as Completions from "@src/completions"
import * as Metadata from "@src/.metadata.generated"
import { excmdsFunctions, getDoc } from "@src/.metadata.generated"
import * as config from "@src/lib/config"
import * as aliases from "@src/lib/aliases"
export class ExcmdCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
export class ExcmdCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse
{
public fuseKeys = []
constructor(public value: string, public documentation: string = "") {
constructor(
public value: string,
public documentation: string = "",
) {
super()
this.fuseKeys.push(this.value)
@ -58,15 +63,13 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
private async updateOptions(exstr = "") {
this.lastExstr = exstr
const excmds = Metadata.everything.getFile("src/excmds.ts")
if (!excmds) return
const fns = excmds.getFunctions()
// Add all excmds that start with exstr and that tridactyl has metadata about to completions
this.options = this.scoreOptions(
fns
.filter(([name, fn]) => !fn.hidden && name.startsWith(exstr))
.map(([name, fn]) => new ExcmdCompletionOption(name, fn.doc)),
Object.entries(excmdsFunctions)
.filter(([name]) => name.startsWith(exstr))
.map(
([name, fn]) => new ExcmdCompletionOption(name, getDoc(fn)),
),
)
// Also narrow down aliases map to possible completions
@ -80,13 +83,13 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
for (const alias of Object.keys(exaliases)) {
const cmd = aliases.expandExstr(alias, exaliases)
const fn = excmds.getFunction(cmd)
const fn = excmdsFunctions[cmd]
if (fn) {
this.options.push(
new ExcmdCompletionOption(
alias,
`Alias for \`${cmd}\`. ${fn.doc}`,
`Alias for \`${cmd}\`. ${getDoc(fn)}`,
),
)
} else {
@ -100,12 +103,11 @@ export class ExcmdCompletionSource extends Completions.CompletionSourceFuse {
// Add partial matched funcs like: 'conf' ~= 'viewconfig'
const seen = new Set(this.options.map(o => o.value))
const partial_options = this.scoreOptions(
fns
.filter(
([name, fn]) =>
!fn.hidden && name.includes(exstr) && !seen.has(name),
)
.map(([name, fn]) => new ExcmdCompletionOption(name, fn.doc)),
Object.entries(excmdsFunctions)
.filter(([name]) => name.includes(exstr) && !seen.has(name))
.map(
([name, fn]) => new ExcmdCompletionOption(name, getDoc(fn)),
),
)
this.options = this.options.concat(partial_options)

View file

@ -1,13 +1,24 @@
import * as Completions from "@src/completions"
import * as Metadata from "@src/.metadata.generated"
import {
excmdsFunctions,
defaultConfigMembers,
getDoc,
memberDoc,
} from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
class HelpCompletionOption extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
class HelpCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse
{
public fuseKeys = []
constructor(public name: string, doc: string, flag: string) {
constructor(
public name: string,
doc: string,
flag: string,
) {
super()
this.value = `${flag} ${name}`
this.html = html`<tr class="HelpCompletionOption option">
@ -42,20 +53,13 @@ export class HelpCompletionSource extends Completions.CompletionSourceFuse {
return
}
const file = Metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const excmds = Metadata.everything.getFile("src/excmds.ts")
const fns = excmds.getFunctions()
const settings = config.get()
const exaliases = settings.exaliases
const bindings = settings.nmaps
if (
fns === undefined ||
exaliases === undefined ||
bindings === undefined
) {
if (exaliases === undefined || bindings === undefined) {
return
}
const fns = Object.entries(excmdsFunctions)
const flags = {
"-a": (options, query) =>
@ -64,9 +68,7 @@ export class HelpCompletionSource extends Completions.CompletionSourceFuse {
.filter(alias => alias.startsWith(query))
.map(alias => {
const cmd = aliases.expandExstr(alias)
const doc =
(excmds.getFunction(cmd) || ({} as any)).doc ||
""
const doc = getDoc(excmdsFunctions[cmd])
return new HelpCompletionOption(
alias,
`Alias for \`${cmd}\`. ${doc}`,
@ -90,15 +92,12 @@ export class HelpCompletionSource extends Completions.CompletionSourceFuse {
"-e": (options, query) =>
options.concat(
fns
.filter(
([name, fn]) =>
!fn.hidden && name.startsWith(query),
)
.filter(([name]) => name.startsWith(query))
.map(
([name, fn]) =>
new HelpCompletionOption(
name,
`Excmd. ${fn.doc}`,
`Excmd. ${getDoc(fn)}`,
"-e",
),
),
@ -108,11 +107,7 @@ export class HelpCompletionSource extends Completions.CompletionSourceFuse {
Object.keys(settings)
.filter(x => x.startsWith(query))
.map(setting => {
const member = default_config.getMember(setting)
let doc = ""
if (member !== undefined) {
doc = member.doc
}
const doc = memberDoc(defaultConfigMembers[setting])
return new HelpCompletionOption(
setting,
`Setting. ${doc}`,

View file

@ -1,10 +1,16 @@
import * as Completions from "@src/completions"
import * as config from "@src/lib/config"
import * as metadata from "@src/.metadata.generated"
import {
defaultConfigMembers,
memberDoc,
memberType,
typeToString,
} from "@src/.metadata.generated"
class SettingsCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
implements Completions.CompletionOptionFuse
{
public fuseKeys = []
constructor(
@ -66,11 +72,9 @@ export class SettingsCompletionSource extends Completions.CompletionSourceFuse {
options += options ? " " : ""
const file = metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const settings = config.get()
if (default_config === undefined || settings === undefined) {
if (settings === undefined) {
return
}
@ -78,18 +82,12 @@ export class SettingsCompletionSource extends Completions.CompletionSourceFuse {
.filter(x => x.startsWith(query))
.sort()
.map(setting => {
const md = default_config.getMember(setting)
let doc = ""
let type = ""
if (md !== undefined) {
doc = md.doc
type = md.type.toString()
}
const md = defaultConfigMembers[setting]
return new SettingsCompletionOption(options + setting, {
name: setting,
value: JSON.stringify(settings[setting]),
doc,
type,
doc: memberDoc(md),
type: md ? typeToString(memberType(md)) : "",
})
})

View file

@ -86,8 +86,7 @@ import * as Logging from "@src/lib/logging"
import { AutoContain } from "@src/lib/autocontainers"
import * as CSS from "css"
import * as Perf from "@src/perf"
import * as Metadata from "@src/.metadata.generated"
import { ObjectType } from "../compiler/types/ObjectType"
import { staticThemes, defaultConfigMembers, memberType, typeKind, convert, convertMember } from "@src/.metadata.generated"
import * as Native from "@src/lib/native"
import * as TTS from "@src/lib/text_to_speech"
import * as excmd_parser from "@src/parsers/exmode"
@ -544,7 +543,7 @@ export async function colourscheme(...args: string[]) {
const themename = option._[0]
// If this is a builtin theme, no need to bother with slow stuff
if (!Metadata.staticThemes.includes(themename)) {
if (!staticThemes.includes(themename)) {
if (themename.search("\\.") >= 0) throw new Error(`Theme name should not contain any dots! (given name: ${themename}).`)
if (url) {
if (themename === undefined) throw new Error(`You must provide a theme name!`)
@ -1427,6 +1426,7 @@ export function scrollto(a: number | string, b: number | "x" | "y" = "y") {
/** @hidden */
//#content_helper
let lineHeight = null
/** Scrolls the document of its first scrollable child element by n lines.
*
* The height of a line is defined by the site's CSS. If Tridactyl can't get it, it'll default to 22 pixels.
@ -4591,16 +4591,15 @@ function validateSetArgs(key: string, values: string[]) {
const target: any[] = key.split(".")
let value
const file = Metadata.everything.getFile("src/lib/config.ts")
const default_config = file.getClass("default_config")
const md = default_config.getMember(target[0])
const md = defaultConfigMembers[target[0]]
if (md !== undefined) {
const strval = values.join(" ")
const t = memberType(md)
// Note: the conversion will throw if strval can't be converted to the right type
if (md.type.kind === "object" && target.length > 1) {
value = (md.type as ObjectType).convertMember(target.slice(1), strval)
if (typeKind(t) === "object" && target.length > 1) {
value = convertMember(t, target.slice(1), strval)
} else {
value = md.type.convert(strval)
value = convert(t, strval)
}
} else {
// If we don't have metadata, fall back to the old way

320
src/lib/metadata.ts Normal file
View file

@ -0,0 +1,320 @@
/**
* Runtime metadata over `typedoc --json` output for src/excmds.ts and
* src/lib/config.ts. Exposes plain-object indexes (keyed by symbol name) plus
* a small set of free helpers that operate on raw typedoc nodes.
*
* @hidden symbols are dropped by typedoc itself before they reach this loader,
* so consumers never see them.
*/
import metadataJson from "../.metadata.generated.json"
import staticThemesJson from "../.themes.generated.json"
export const staticThemes: string[] = staticThemesJson
// =============================================================================
// Indexes — built once at module load by walking the typedoc tree.
// =============================================================================
type Node = any
const fileBuckets: Record<
string,
{ functions: Record<string, Node>; classes: Record<string, Node> }
> = {
"src/excmds.ts": { functions: {}, classes: {} },
"src/lib/config.ts": { functions: {}, classes: {} },
}
function inTargetFile(node: Node): string | undefined {
for (const src of node.sources || []) {
if (fileBuckets[src.fileName]) return src.fileName
}
return undefined
}
function walk(node: Node) {
if (!node || typeof node !== "object") return
if (Array.isArray(node)) {
for (const v of node) walk(v)
return
}
if (node.kindString === "Function") {
const f = inTargetFile(node)
if (f) fileBuckets[f].functions[node.name] = node
} else if (node.kindString === "Class") {
const f = inTargetFile(node)
if (f) fileBuckets[f].classes[node.name] = node
}
if (node.children) walk(node.children)
}
walk(metadataJson)
export const excmdsFunctions: Record<string, Node> =
fileBuckets["src/excmds.ts"].functions
const defaultConfig: Node | undefined =
fileBuckets["src/lib/config.ts"].classes["default_config"]
export const defaultConfigMembers: Record<string, Node> = (() => {
const out: Record<string, Node> = {}
for (const ch of defaultConfig?.children || []) out[ch.name] = ch
return out
})()
// =============================================================================
// Doc / type access on function and class-member nodes.
// =============================================================================
function readComment(c: Node | undefined): string {
if (!c) return ""
let s: string = c.shortText || ""
if (c.text) s += (s ? "\n\n" : "") + c.text
return s.replace(/\n+$/, "")
}
export function getDoc(node: Node | undefined): string {
if (!node) return ""
return (
readComment(node.signatures?.[0]?.comment) || readComment(node.comment)
)
}
export function memberDoc(node: Node | undefined): string {
if (!node) return ""
if (node.kindString === "Accessor") {
const sig = (node.getSignature || [])[0] || (node.setSignature || [])[0]
if (sig?.comment) return readComment(sig.comment)
}
return readComment(node.comment)
}
/**
* Returns a typedoc type node for a class member, synthesising an empty
* reflection for `Object literal` fields (which have no annotated type) so
* downstream helpers can treat them uniformly as objects.
*/
export function memberType(node: Node | undefined): Node | undefined {
if (!node) return undefined
if (node.type) return node.type
if (node.kindString === "Object literal") {
return { type: "reflection", declaration: { children: [] } }
}
if (node.kindString === "Accessor") {
const sig = (node.getSignature || [])[0] || (node.setSignature || [])[0]
return sig?.type
}
return undefined
}
/** Parameter list for a function node — each entry has `name`, `type`, `flags`. */
export function paramTypes(fnNode: Node | undefined): Node[] {
return fnNode?.signatures?.[0]?.parameters || []
}
// =============================================================================
// Type coercion + stringification over typedoc type nodes.
// =============================================================================
function intrinsicName(t) {
switch (t.name) {
case "string":
case "number":
case "boolean":
case "object":
return t.name
case "void":
case "undefined":
case "never":
return "void"
default:
return "any"
}
}
/** Normalised kind: "string" | "number" | "boolean" | "object" | "array" | "void" | "any" | ... */
export function typeKind(t: Node | undefined): string {
if (!t) return "any"
switch (t.type) {
case "intrinsic":
return intrinsicName(t)
case "array":
case "tuple":
case "union":
return t.type
case "reflection":
return t.declaration?.signatures?.length ? "function" : "object"
case "stringLiteral":
case "numberLiteral":
case "booleanLiteral":
return "LiteralType"
case "reference":
return t.name || "reference"
default:
return "any"
}
}
export function typeToString(t: Node | undefined): string {
if (!t) return "any"
switch (t.type) {
case "intrinsic":
return t.name
case "array":
return `${typeToString(t.elementType)}[]`
case "tuple":
return `[${(t.elements || []).map(typeToString).join(", ")}]`
case "union":
return (t.types || []).map(typeToString).join(" | ")
case "stringLiteral":
case "numberLiteral":
case "booleanLiteral":
return JSON.stringify(t.value)
case "reference": {
const args = (t.typeArguments || []).map(typeToString)
return args.length
? `${t.name}<${args.join(", ")}>`
: t.name || "reference"
}
case "reflection": {
const decl = t.declaration || {}
if (decl.signatures?.length) {
const sig = decl.signatures[0]
const args = (sig.parameters || []).map((p: Node) =>
typeToString(p.type),
)
return `(${args.join(", ")}) => ${typeToString(sig.type)}`
}
return "object"
}
default:
return "any"
}
}
function convertIntrinsic(t) {
switch (t.name) {
case "string":
if (typeof value === "string") return value
throw new Error(`Can't convert to string: ${value}`)
case "number": {
const n = parseFloat(value)
if (!Number.isNaN(n)) return n
throw new Error(`Can't convert to number: ${value}`)
}
case "boolean":
if (value === "true") return true
if (value === "false") return false
throw new Error(`Can't convert ${value} to boolean`)
case "object":
try {
return JSON.parse(value)
} catch {
throw new Error(`Can't convert to object: ${value}`)
}
case "void":
case "undefined":
case "never":
return null
default:
return value
}
}
export function convert(t: Node | undefined, value: any): any {
if (!t) return value
switch (t.type) {
case "intrinsic":
return convertIntrinsic(t)
case "array": {
let arr = value
if (!Array.isArray(arr)) {
try {
arr = JSON.parse(arr)
} catch {
throw new Error(`Can't convert ${value} to array:`)
}
if (!Array.isArray(arr))
throw new Error(`Can't convert ${value} to array:`)
}
return arr.map((v: any) => convert(t.elementType, v))
}
case "tuple": {
let arr = value
if (!Array.isArray(arr)) {
try {
arr = JSON.parse(arr)
} catch {
throw new Error(`Can't convert to tuple: ${value}`)
}
if (!Array.isArray(arr))
throw new Error(`Can't convert to tuple: ${value}`)
}
const elems = t.elements || []
if (arr.length !== elems.length) {
throw new Error(
`Error converting tuple: number of elements and type mismatch ${value}`,
)
}
return arr.map((v: any, i: number) => convert(elems[i], v))
}
case "union":
for (const u of t.types || []) {
try {
return convert(u, value)
} catch {}
}
throw new Error(
`Can't convert "${value}" to any of: ${(t.types || []).map(typeToString).join(", ")}`,
)
case "stringLiteral":
case "numberLiteral":
case "booleanLiteral":
if (value === t.value) return value
throw new Error(
`Argument does not match expected value (${t.value}): ${value}`,
)
case "reflection": {
const decl = t.declaration || {}
if (decl.signatures?.length) {
throw new Error(
`Conversion to function not implemented: ${value}`,
)
}
try {
return JSON.parse(value)
} catch {
throw new Error(`Can't convert to object: ${value}`)
}
}
case "reference":
throw new Error(
"Conversion of simple type references not implemented.",
)
default:
return value
}
}
/** Walk `path` through an object-type reflection, coercing `value` against the matched leaf. */
export function convertMember(
t: Node | undefined,
path: string[],
value: any,
): any {
const decl = t?.type === "reflection" ? t.declaration : undefined
const named: Record<string, Node> = {}
let indexSig: Node | undefined
for (const ch of decl?.children || []) {
if (ch.name && ch.type) named[ch.name] = ch.type
}
const idx = decl?.indexSignature
const idxArr = Array.isArray(idx) ? idx : idx ? [idx] : []
for (const sig of idxArr) if (sig?.type) indexSig = sig.type
const sub = named[path[0]] ?? indexSig
if (!sub) return value
if (typeKind(sub) === "object")
return convertMember(sub, path.slice(1), value)
return convert(sub, value)
}

View file

@ -1,26 +1,20 @@
/** Ex Mode (AKA cmd mode) */
import { FunctionType } from "../../compiler/types/AllTypes"
import { everything as metadata } from "@src/.metadata.generated"
import { excmdsFunctions, paramTypes, convert } from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as Logging from "@src/lib/logging"
const logger = new Logging.Logger("exmode")
function convertArgs(types, argv) {
function convertArgs(params, argv) {
const typedArgs = []
for (
let itypes = 0, iargv = 0;
itypes < types.length && iargv < argv.length;
++itypes && ++iargv
) {
const curType = types[itypes]
const curArg = argv[iargv]
for (let i = 0, j = 0; i < params.length && j < argv.length; ++i && ++j) {
const p = params[i]
// Special casing arrays because that's why the previous arg conversion code did
if (curType.isDotDotDot || curType.kind === "array") {
return typedArgs.concat(curType.convert(argv.slice(iargv)))
if (p.flags?.isRest || p.type?.type === "array") {
return typedArgs.concat(convert(p.type, argv.slice(j)))
}
typedArgs.push(curType.convert(curArg))
typedArgs.push(convert(p.type, argv[j]))
}
return typedArgs
}
@ -47,18 +41,13 @@ export function parser(exstr: string, all_excmds: any): any[] {
// Convert arguments, but only for ex commands
let converted_args
if (namespce == "" && args.length > 0) {
let types
try {
types = (metadata.getFile("src/excmds.ts").getFunction(funcName)
.type as FunctionType).args
} catch (e) {
const fn = excmdsFunctions[funcName]
if (!fn) {
// user defined functions?
types = null
converted_args = args
}
if (types !== null) {
} else {
try {
converted_args = convertArgs(types, args)
converted_args = convertArgs(paramTypes(fn), args)
} catch (e) {
logger.error("Error executing or parsing:", exstr, e)
throw e

View file

@ -3,6 +3,7 @@
"moduleResolution": "node",
"module": "es2020",
"esModuleInterop": true,
"resolveJsonModule": true,
"noImplicitAny": false,
"noEmitOnError": true,
"outDir": "build/tsc-out",