Files
misode.github.io/src/app/services/Spyglass.ts
2024-11-18 17:16:38 +01:00

493 lines
16 KiB
TypeScript

import * as core from '@spyglassmc/core'
import { BrowserExternals } from '@spyglassmc/core/lib/browser.js'
import * as je from '@spyglassmc/java-edition'
import { ReleaseVersion } from '@spyglassmc/java-edition/lib/dependency/index.js'
import * as json from '@spyglassmc/json'
import { localize } from '@spyglassmc/locales'
import * as mcdoc from '@spyglassmc/mcdoc'
import * as nbt from '@spyglassmc/nbt'
import * as zip from '@zip.js/zip.js'
import { TextDocument } from 'vscode-languageserver-textdocument'
import type { ConfigGenerator } from '../Config.js'
import siteConfig from '../Config.js'
import { computeIfAbsent, genPath, message } from '../Utils.js'
import type { VersionMeta } from './DataFetcher.js'
import { fetchBlockStates, fetchRegistries, fetchVanillaMcdoc, fetchVersions, getVersionChecksum } from './DataFetcher.js'
import { IndexedDbFileSystem, MixedFileSystem } from './FileSystem.js'
import type { VersionId } from './Versions.js'
export const CACHE_URI = 'file:///cache/'
export const ROOT_URI = 'file:///root/'
export const DEPENDENCY_URI = `${ROOT_URI}dependency/`
export const UNSAVED_URI = `${ROOT_URI}unsaved/`
export const PROJECTS_URI = `${ROOT_URI}projects/`
const builtinMcdoc = `
use ::java::server::util::text::Text
use ::java::data::worldgen::dimension::Dimension
dispatch minecraft:resource[text_component] to Text
dispatch minecraft:resource[world] to struct WorldSettings {
seed: #[random] long,
/// Defaults to \`true\`.
generate_features?: boolean,
/// Defaults to \`false\`.
bonus_chest?: boolean,
legacy_custom_options?: string,
dimensions: struct {
[#[id="dimension"] string]: Dimension,
},
}
`
interface ClientDocument {
doc: TextDocument
undoStack: string[]
redoStack: string[]
}
export class SpyglassClient {
public readonly fs = new MixedFileSystem(new IndexedDbFileSystem(), [
// { prefix: DEPENDENCY_URI, fs: new MemoryFileSystem() },
])
public readonly externals: core.Externals = {
...BrowserExternals,
archive: {
...BrowserExternals.archive,
decompressBall,
},
fs: this.fs,
}
public readonly documents = new Map<string, ClientDocument>()
public async createService(version: VersionId) {
return SpyglassService.create(version, this)
}
}
export class SpyglassService {
private readonly watchers = new Map<string, ((docAndNode: core.DocAndNode) => void)[]>()
private constructor (
public readonly version: VersionId,
private readonly service: core.Service,
private readonly client: SpyglassClient,
) {
service.project.on('documentUpdated', (e) => {
const uriWatchers = this.watchers.get(e.doc.uri) ?? []
for (const handler of uriWatchers) {
handler(e)
}
})
}
public getCheckerContext(doc?: TextDocument, errors?: core.LanguageError[]) {
if (!doc) {
doc = TextDocument.create('file:///unknown.json', 'json', 1, '')
}
const err = new core.ErrorReporter()
if (errors) {
err.errors = errors
}
return core.CheckerContext.create(this.service.project, { doc, err })
}
public dissectUri(uri: string) {
return je.binder.dissectUri(uri, this.getCheckerContext(TextDocument.create(uri, 'json', 1, '')))
}
public async openFile(uri: string) {
const lang = core.fileUtil.extname(uri)?.slice(1) ?? 'txt'
const content = await this.readFile(uri)
if (!content) {
return undefined
}
await this.service.project.onDidOpen(uri, lang, 1, content)
const docAndNode = await this.service.project.ensureClientManagedChecked(uri)
if (!docAndNode) {
return undefined
}
const document = this.client.documents.get(uri)
if (document === undefined) {
this.client.documents.set(uri, { doc: docAndNode.doc, undoStack: [], redoStack: [] })
}
return docAndNode
}
public async readFile(uri: string): Promise<string | undefined> {
try {
const buffer = await this.service.project.externals.fs.readFile(uri)
return new TextDecoder().decode(buffer)
} catch (e) {
return undefined
}
}
private async notifyChange(doc: TextDocument) {
await this.service.project.onDidChange(doc.uri, [{ text: doc.getText() }], doc.version + 1)
await this.service.project.ensureClientManagedChecked(doc.uri)
}
public async writeFile(uri: string, content: string) {
const document = this.client.documents.get(uri)
if (document !== undefined) {
document.undoStack.push(document.doc.getText())
document.redoStack = []
TextDocument.update(document.doc, [{ text: content }], document.doc.version + 1)
}
await this.service.project.externals.fs.writeFile(uri, content)
if (document) {
await this.notifyChange(document.doc)
}
}
public async applyEdit(uri: string, edit: (node: core.FileNode<core.AstNode>) => void) {
const document = this.client.documents.get(uri)
if (document !== undefined) {
document.undoStack.push(document.doc.getText())
document.redoStack = []
const docAndNode = this.service.project.getClientManaged(uri)
if (!docAndNode) {
throw new Error(`[Spyglass#applyEdit] Cannot get doc and node: ${uri}`)
}
edit(docAndNode.node)
const newText = this.service.format(docAndNode.node, docAndNode.doc, 2, true)
TextDocument.update(document.doc, [{ text: newText }], document.doc.version + 1)
await this.service.project.externals.fs.writeFile(uri, document.doc.getText())
await this.notifyChange(document.doc)
}
}
public formatNode(node: json.JsonNode, uri: string) {
const formatter = this.service.project.meta.getFormatter(node.type)
const doc = TextDocument.create(uri, 'json', 1, '')
const ctx = core.FormatterContext.create(this.service.project, { doc, tabSize: 2, insertSpaces: true })
return formatter(node, ctx)
}
public async undoEdit(uri: string) {
const document = this.client.documents.get(uri)
if (document === undefined) {
throw new Error(`[Spyglass#undoEdits] Document doesn't exist: ${uri}`)
}
const lastUndo = document.undoStack.pop()
if (lastUndo === undefined) {
return
}
document.redoStack.push(document.doc.getText())
TextDocument.update(document.doc, [{ text: lastUndo }], document.doc.version + 1)
await this.service.project.externals.fs.writeFile(uri, document.doc.getText())
await this.notifyChange(document.doc)
}
public async redoEdit(uri: string) {
const document = this.client.documents.get(uri)
if (document === undefined) {
throw new Error(`[Spyglass#redoEdits] Document doesn't exist: ${uri}`)
}
const lastRedo = document.redoStack.pop()
if (lastRedo === undefined) {
return
}
document.undoStack.push(document.doc.getText())
TextDocument.update(document.doc, [{ text: lastRedo }], document.doc.version + 1)
await this.service.project.externals.fs.writeFile(uri, document.doc.getText())
await this.notifyChange(document.doc)
}
public getUnsavedFileUri(gen: ConfigGenerator) {
if (gen.id === 'pack_mcmeta') {
return `${UNSAVED_URI}pack.mcmeta`
}
return `${UNSAVED_URI}data/draft/${genPath(gen, this.version)}/draft.json`
}
public watchFile(uri: string, handler: (docAndNode: core.DocAndNode) => void) {
const uriWatchers = computeIfAbsent(this.watchers, uri, () => [])
uriWatchers.push(handler)
}
public unwatchFile(uri: string, handler: (docAndNode: core.DocAndNode) => void) {
const uriWatchers = computeIfAbsent(this.watchers, uri, () => [])
const index = uriWatchers.findIndex(w => w === handler)
uriWatchers.splice(index, 1)
}
public static async create(versionId: VersionId, client: SpyglassClient) {
const version = siteConfig.versions.find(v => v.id === versionId)!
const logger = console
const service = new core.Service({
logger,
profilers: new core.ProfilerFactory(logger, [
'cache#load',
'cache#save',
'project#init',
'project#ready',
'project#ready#bind',
]),
project: {
cacheRoot: CACHE_URI,
projectRoots: [ROOT_URI],
externals: client.externals,
defaultConfig: core.ConfigService.merge(core.VanillaConfig, {
env: {
gameVersion: version.ref ?? version.id,
dependencies: ['@vanilla-mcdoc', '@misode-mcdoc'],
customResources: {
text_component: {
category: 'text_component',
},
world: {
category: 'world',
},
// TODO: move these to the assets folder
atlases: {
category: 'atlas',
},
blockstates: {
category: 'block_definition',
},
font: {
category: 'font',
},
models: {
category: 'model',
},
// Partner resources
...Object.fromEntries(siteConfig.generators.filter(gen => gen.dependency).map(gen =>
[gen.path ?? gen.id, {
category: gen.id,
}]
)),
},
},
lint: {
idOmitDefaultNamespace: false,
undeclaredSymbol: [
{
if: { category: ['bossbar', 'objective', 'team'] },
then: { declare: 'block' },
},
...core.VanillaConfig.lint.undeclaredSymbol as any[],
],
},
}),
initializers: [mcdoc.initialize, initialize],
},
})
await service.project.ready()
setTimeout(() => {
service.project.cacheService.save()
}, 10_000)
return new SpyglassService(versionId, service, client)
}
}
async function decompressBall(buffer: Uint8Array, options?: { stripLevel?: number }): Promise<core.DecompressedFile[]> {
const reader = new zip.ZipReader(new zip.BlobReader(new Blob([buffer])))
const entries = await reader.getEntries()
return await Promise.all(entries.map(async e => {
const data = await e.getData?.(new zip.Uint8ArrayWriter())
const path = options?.stripLevel === 1 ? e.filename.substring(e.filename.indexOf('/') + 1) : e.filename
const type = e.directory ? 'dir' : 'file'
return { data, path, mtime: '', type, mode: 0 }
}))
}
async function compressBall(files: [string, string][]): Promise<Uint8Array> {
const writer = new zip.ZipWriter(new zip.Uint8ArrayWriter())
await Promise.all(files.map(async ([name, data]) => {
await writer.add(name, new zip.TextReader(data))
}))
return await writer.close()
}
const initialize: core.ProjectInitializer = async (ctx) => {
const { config, logger, meta, externals, cacheRoot } = ctx
meta.registerDependencyProvider('@vanilla-mcdoc', async () => {
const uri: string = new core.Uri('downloads/vanilla-mcdoc.tar.gz', cacheRoot).toString()
const buffer = await fetchVanillaMcdoc()
await core.fileUtil.writeFile(externals, uri, new Uint8Array(buffer))
return { info: { startDepth: 1 }, uri }
})
meta.registerDependencyProvider('@misode-mcdoc', async () => {
const uri: string = new core.Uri('downloads/misode-mcdoc.tar.gz', cacheRoot).toString()
const buffer = await compressBall([['builtin.mcdoc', builtinMcdoc]])
await core.fileUtil.writeFile(externals, uri, buffer)
return { uri }
})
const versions = await fetchVersions()
const release = config.env.gameVersion as ReleaseVersion
const version = siteConfig.versions.find(v => {
return v.ref ? v.ref === release : v.id === release
})
if (version === undefined) {
logger.error(`[initialize] Failed finding game version matching ${release}.`)
return
}
const summary: je.dependency.McmetaSummary = {
registries: Object.fromEntries((await fetchRegistries(version.id)).entries()),
blocks: Object.fromEntries([...(await fetchBlockStates(version.id)).entries()]
.map(([id, data]) => [id, data])),
fluids: je.dependency.Fluids,
commands: { type: 'root', children: {} },
}
const versionChecksum = getVersionChecksum(version.id)
meta.registerSymbolRegistrar('mcmeta-summary', {
checksum: versionChecksum,
registrar: je.dependency.symbolRegistrar(summary),
})
meta.registerSymbolRegistrar('mcdoc-block-states', {
checksum: versionChecksum,
registrar: (symbols) => {
const uri = 'mcmeta://summary/block_states.json'
symbols.query(uri, 'mcdoc/dispatcher', 'mcdoc:block_states').enter({
usage: { type: 'declaration' },
}).onEach(Object.entries(summary.blocks), ([id, [properties]], blockQuery) => {
const data: mcdoc.binder.TypeDefSymbolData = { typeDef: {
kind: 'struct',
fields: Object.entries(properties).map(([propKey, propValues]) => ({
kind: 'pair',
key: propKey,
type: {
kind: 'union',
members: propValues.map(value => ({
kind: 'literal', value: { kind: 'string', value },
})),
},
})),
} }
blockQuery.member(id, (stateQuery) => {
stateQuery.enter({
data: { data },
usage: { type: 'declaration' },
})
})
})
symbols.query(uri, 'mcdoc/dispatcher', 'mcdoc:block_state_keys').enter({
usage: { type: 'declaration' },
}).onEach(Object.entries(summary.blocks), ([id, [properties]], blockQuery) => {
const data: mcdoc.binder.TypeDefSymbolData = { typeDef: {
kind: 'union',
members: Object.keys(properties).map(propKey => ({
kind: 'literal',
value: { kind: 'string', value: propKey },
})),
} }
blockQuery.member(id, (stateQuery) => {
stateQuery.enter({
data: { data },
usage: { type: 'declaration' },
})
})
})
},
})
registerAttributes(meta, release, versions)
json.initialize(ctx)
je.json.initialize(ctx)
je.mcf.initialize(ctx, summary.commands, release)
nbt.initialize(ctx)
// Until spyglass registers these correctly
meta.registerFormatter<json.JsonFileNode>('json:file', (node, ctx) => {
return ctx.meta.getFormatter(node.children[0].type)(node.children[0], ctx)
})
meta.registerFormatter<json.JsonStringNode>('json:string', (node) => {
return JSON.stringify(node.value)
})
meta.registerFormatter<core.ErrorNode>('error', () => {
return ''
})
return { loadedVersion: release }
}
// Duplicate these from spyglass for now, until they are exported separately
function registerAttributes(meta: core.MetaRegistry, release: ReleaseVersion, versions: VersionMeta[]) {
mcdoc.runtime.registerAttribute(meta, 'since', mcdoc.runtime.attribute.validator.string, {
filterElement: (config, ctx) => {
if (!config.startsWith('1.')) {
ctx.logger.warn(`Invalid mcdoc attribute for "since": ${config}`)
return true
}
return ReleaseVersion.cmp(release, config as ReleaseVersion) >= 0
},
})
mcdoc.runtime.registerAttribute(meta, 'until', mcdoc.runtime.attribute.validator.string, {
filterElement: (config, ctx) => {
if (!config.startsWith('1.')) {
ctx.logger.warn(`Invalid mcdoc attribute for "until": ${config}`)
return true
}
return ReleaseVersion.cmp(release, config as ReleaseVersion) < 0
},
})
mcdoc.runtime.registerAttribute(
meta,
'deprecated',
mcdoc.runtime.attribute.validator.optional(mcdoc.runtime.attribute.validator.string),
{
mapField: (config, field, ctx) => {
if (config === undefined) {
return { ...field, deprecated: true }
}
if (!config.startsWith('1.')) {
ctx.logger.warn(`Invalid mcdoc attribute for "deprecated": ${config}`)
return field
}
if (ReleaseVersion.cmp(release, config as ReleaseVersion) >= 0) {
return { ...field, deprecated: true }
}
return field
},
},
)
const maxPackFormat = versions[0].data_pack_version
mcdoc.runtime.registerAttribute(meta, 'pack_format', () => undefined, {
checker: (_, typeDef) => {
if (typeDef.kind !== 'literal' || typeof typeDef.value.value !== 'number') {
return undefined
}
const target = typeDef.value.value
return (node, ctx) => {
if (target > maxPackFormat) {
ctx.err.report(
localize('expected', localize('mcdoc.runtime.checker.range.number', localize('mcdoc.runtime.checker.range.right-inclusive', maxPackFormat))),
node,
3,
)
}
}
},
})
// Until spyglass implements this attribute itself
mcdoc.runtime.registerAttribute(meta, 'regex_pattern', () => undefined, {
checker: (_, typeDef) => {
if (typeDef.kind !== 'literal' || typeDef.value.kind !== 'string') {
return undefined
}
const pattern = typeDef.value.value
return (node, ctx) => {
try {
RegExp(pattern)
} catch (e) {
ctx.err.report(message(e), node, 2)
}
}
},
})
}