@lincyaw/language-server 1.0.0-rc.0 → 1.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/package.json +9 -8
- package/src/domain/document.ts +92 -0
- package/src/domain/index.ts +3 -8
- package/src/domain/messages.ts +2 -10
- package/src/domain/{preview.ts → preview-url.ts} +2 -2
- package/src/domain/protocol.ts +2 -1
- package/src/node/anchors.ts +18 -79
- package/src/node/decks.ts +27 -45
- package/src/node/{preview.ts → dev-server-launcher.ts} +12 -34
- package/src/node/diagnostics.ts +29 -28
- package/src/node/document-cli.ts +19 -0
- package/src/node/server.ts +81 -89
- package/src/domain/directives.ts +0 -35
- package/src/domain/names.ts +0 -27
- package/src/domain/sections.ts +0 -26
- package/src/domain/syntax.ts +0 -84
- package/src/node/completion.ts +0 -66
- package/src/node/syntax.ts +0 -30
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @lincyaw/language-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Language server for canonical `lecture.json` teaching documents: shared validation, normalized entity definitions, project code anchor definitions and hovers, and preview synchronization by stable page ownership.
|
|
4
4
|
|
|
5
|
-
Part of [Presmium](https://github.com/Lincyaw/presmium), which
|
|
5
|
+
Part of [Presmium](https://github.com/Lincyaw/presmium), which derives web pages, slides, Jupyter notebooks and narrated playback from the same teaching document.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
@@ -10,11 +10,17 @@ Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures wr
|
|
|
10
10
|
pnpm exec presmium-language-server --stdio
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
It also takes `--node-ipc` and `--socket=<port>`. The VS Code extension
|
|
13
|
+
It also takes `--node-ipc` and `--socket=<port>`. Clients select `**/lecture.json` using their native JSON editor and associate the public kernel schema for completion. The VS Code extension generates and bundles this schema from `DOCUMENT_JSON_SCHEMA`; the server adds syntax and domain graph diagnostics using `validateDocument`.
|
|
14
|
+
|
|
15
|
+
Opened buffers are validated without writing or compiling unsaved text. Saving JSON and notifications for watched project inputs trigger a real compiler run. Diagnostics resolve JSON pointers to token ranges; entity and `origin.anchor` definitions navigate to their authoritative JSON or project code. `presmium/section` locates a page from the entity under the cursor, including normalized blocks, steps and narration. `presmium/preview` starts a real `presmium dev` web server for the canonical entry and stops it when the language server closes.
|
|
16
|
+
|
|
17
|
+
Compilation and preview prefer the document's installed `@lincyaw/presmium` package, which supplies its bundled plugins and renderers. A standalone installation uses the project's CLI or the language server's own CLI dependencies.
|
|
18
|
+
|
|
19
|
+
The server does not register its own completion provider: JSON schema completion remains with the editor. The studio provides native rich-text editing of the same `lecture.json` document.
|
|
14
20
|
|
|
15
21
|
## Documentation
|
|
16
22
|
|
|
17
|
-
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.
|
|
23
|
+
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json)
|
|
18
24
|
|
|
19
25
|
## License
|
|
20
26
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/language-server",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
4
|
-
"description": "Language server for
|
|
3
|
+
"version": "1.0.0-rc.10",
|
|
4
|
+
"description": "Language server for canonical lecture.json documents: validation, entity navigation, code anchors and preview sync",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -36,14 +36,15 @@
|
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"jiti": "^2.7.0",
|
|
39
|
+
"jsonc-parser": "3.3.1",
|
|
39
40
|
"vscode-languageserver": "10.1.2",
|
|
40
41
|
"vscode-languageserver-textdocument": "1.0.15",
|
|
41
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
42
|
-
"@lincyaw/
|
|
43
|
-
"@lincyaw/
|
|
44
|
-
"@lincyaw/
|
|
45
|
-
"@lincyaw/
|
|
46
|
-
"@lincyaw/
|
|
42
|
+
"@lincyaw/cli": "1.0.0-rc.10",
|
|
43
|
+
"@lincyaw/host": "1.0.0-rc.10",
|
|
44
|
+
"@lincyaw/web": "1.0.0-rc.10",
|
|
45
|
+
"@lincyaw/compiler": "1.0.0-rc.10",
|
|
46
|
+
"@lincyaw/viewer": "1.0.0-rc.10",
|
|
47
|
+
"@lincyaw/kernel": "1.0.0-rc.10"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
50
|
"typecheck": "tsc -p tsconfig.json"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isRecord, validateDocument } from '@lincyaw/kernel'
|
|
2
|
+
import type { TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { findNodeAtLocation, findNodeAtOffset, getNodePath, getNodeValue, parseTree } from 'jsonc-parser'
|
|
4
|
+
import type { JSONPath, Node, ParseError } from 'jsonc-parser'
|
|
5
|
+
|
|
6
|
+
/** Strict JSON syntax, using parser offsets unchanged even when the entry has a BOM. */
|
|
7
|
+
export function readDocument(text: string) {
|
|
8
|
+
const errors: ParseError[] = []
|
|
9
|
+
const tree = parseTree(text.replace(/^\uFEFF/, ' '), errors, { disallowComments: true, allowTrailingComma: false, allowEmptyContent: false })
|
|
10
|
+
const validation = tree && !errors.length ? validateDocument(getNodeValue(tree)) : undefined
|
|
11
|
+
return { tree, errors, validation, document: validation?.document }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nodeAt(tree: Node, offset: number): { node: Node, path: JSONPath } | undefined {
|
|
15
|
+
const node = findNodeAtOffset(tree, offset, true)
|
|
16
|
+
return node ? { node, path: getNodePath(node) } : undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Missing properties point at the closest existing parent, rather than an invented source line. */
|
|
20
|
+
export function nodeAtPointer(tree: Node, pointer: string): Node {
|
|
21
|
+
const path = pointer.replace(/^#/, '').split('/').slice(1).map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
22
|
+
let node = tree
|
|
23
|
+
for (const key of path) {
|
|
24
|
+
const found = findNodeAtLocation(node, [node.type === 'array' ? Number(key) : key])
|
|
25
|
+
if (!found) break
|
|
26
|
+
node = found
|
|
27
|
+
}
|
|
28
|
+
return node
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function owners(document: TeachingDocument): Map<string, string> {
|
|
32
|
+
const result = new Map<string, string>()
|
|
33
|
+
const rich = (node: unknown, section: string) => {
|
|
34
|
+
if (!isRecord(node)) return
|
|
35
|
+
if (typeof node.id === 'string') result.set(node.id, section)
|
|
36
|
+
if (Array.isArray(node.children)) node.children.forEach(child => rich(child, section))
|
|
37
|
+
if (Array.isArray(node.fallback)) node.fallback.forEach(child => rich(child, section))
|
|
38
|
+
}
|
|
39
|
+
const visit = (id: string, section: string) => {
|
|
40
|
+
result.set(id, section)
|
|
41
|
+
const block = document.blocks[id]!
|
|
42
|
+
if (block.type === 'container' || block.type === 'component') block.children?.forEach(child => visit(child, section))
|
|
43
|
+
if (block.type === 'text') block.content.forEach(node => rich(node, section))
|
|
44
|
+
}
|
|
45
|
+
for (const id of document.sectionOrder) {
|
|
46
|
+
result.set(`page:${id}`, id)
|
|
47
|
+
document.sections[id]!.children.forEach(child => visit(child, id))
|
|
48
|
+
}
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Entity ownership determines the page even if normalized maps are reordered or minified. */
|
|
53
|
+
export function sectionAtPath(document: TeachingDocument, path: JSONPath): string | undefined {
|
|
54
|
+
const [map, id, field, key] = path
|
|
55
|
+
const owner = owners(document)
|
|
56
|
+
const narration = (id: string) => document.sectionOrder.find(section => document.sections[section]!.stepOrder.some(step => document.steps[step]!.narrationIds.includes(id)))
|
|
57
|
+
const usedBlock = (predicate: (block: TeachingDocument['blocks'][string]) => boolean) => Object.values(document.blocks).find(predicate)?.id
|
|
58
|
+
if (map === 'sections' && typeof id === 'string') return document.sections[id]?.id
|
|
59
|
+
if (map === 'sectionOrder' && typeof id === 'number') return document.sectionOrder[id]
|
|
60
|
+
if (map === 'blocks' && typeof id === 'string') return owner.get(id)
|
|
61
|
+
if (map === 'steps' && typeof id === 'string') return document.steps[id]?.sectionId
|
|
62
|
+
if (map === 'narrations' && typeof id === 'string') return narration(id)
|
|
63
|
+
if (map === 'definitions' && typeof id === 'string') return owner.get(usedBlock(block => block.type === 'component' && block.definitionId === id) ?? '')
|
|
64
|
+
if (map === 'assets' && typeof id === 'string') return owner.get(usedBlock(block => block.type === 'image' && block.assetId === id) ?? '')
|
|
65
|
+
if (map === 'production') {
|
|
66
|
+
if (id === 'sectionOverrides' && typeof field === 'string') return document.sections[field]?.id
|
|
67
|
+
if (id === 'stepOverrides' && typeof field === 'string') return document.steps[field]?.sectionId
|
|
68
|
+
if (id === 'narrationOverrides' && typeof field === 'string') return narration(field)
|
|
69
|
+
if (id === 'cueTimings' && typeof field === 'string') {
|
|
70
|
+
const owner = Object.values(document.narrations).find(value => value.content.some(node => node.type === 'cue' && node.id === field))
|
|
71
|
+
return owner && narration(owner.id)
|
|
72
|
+
}
|
|
73
|
+
if (id === 'mediaClips' && typeof field === 'number') return document.steps[document.production.mediaClips[field]?.attachment.stepId ?? '']?.sectionId
|
|
74
|
+
}
|
|
75
|
+
if (map === 'comments' && typeof id === 'string') return owner.get(document.comments?.[id]?.anchor?.[key === 'to' ? 'to' : 'from'].blockId ?? '')
|
|
76
|
+
return map === 'metadata' || !path.length ? document.sectionOrder[0] : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function entityReference(document: TeachingDocument, path: JSONPath, value: unknown): JSONPath | undefined {
|
|
80
|
+
if (typeof value !== 'string') return undefined
|
|
81
|
+
const [root, , field] = path, last = path.at(-1)
|
|
82
|
+
const child = path.length === 4 && field === 'children' && (root === 'sections' || root === 'blocks')
|
|
83
|
+
const visual = root === 'steps' && field === 'visuals' && path.length === 5 && last === 'blockId'
|
|
84
|
+
const cue = root === 'narrations' && field === 'content' && path.length === 6 && path.at(-2) === 'target' && last === 'blockId'
|
|
85
|
+
const asset = last === 'assetId' && (root === 'blocks' && path.length === 3 || root === 'production' && (path.at(-2) === 'voiceSource' || path[1] === 'mediaClips' && path.length === 4))
|
|
86
|
+
const map = root === 'sectionOrder' && path.length === 2 || root === 'steps' && field === 'sectionId' && path.length === 3 ? 'sections'
|
|
87
|
+
: root === 'blocks' && field === 'definitionId' && path.length === 3 ? 'definitions'
|
|
88
|
+
: asset ? 'assets' : child || visual || cue ? 'blocks'
|
|
89
|
+
: root === 'sections' && field === 'stepOrder' && path.length === 4 || root === 'steps' && field === 'inherits' && path.length === 3 || root === 'production' && path[1] === 'mediaClips' && path.at(-2) === 'attachment' && last === 'stepId' ? 'steps'
|
|
90
|
+
: root === 'steps' && field === 'narrationIds' && path.length === 4 ? 'narrations' : undefined
|
|
91
|
+
return map && Object.hasOwn(document[map], value) ? [map, value] : undefined
|
|
92
|
+
}
|
package/src/domain/index.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type { DirectiveInfo, OptionInfo } from './directives.ts'
|
|
1
|
+
export { entityReference, nodeAt, nodeAtPointer, readDocument, sectionAtPath } from './document.ts'
|
|
3
2
|
export { LS_MESSAGES } from './messages.ts'
|
|
4
3
|
export type { LsMessages } from './messages.ts'
|
|
5
|
-
export {
|
|
6
|
-
export { previewUrl } from './preview.ts'
|
|
4
|
+
export { previewUrl } from './preview-url.ts'
|
|
7
5
|
export { PREVIEW_REQUEST, SECTION_REQUEST } from './protocol.ts'
|
|
8
|
-
export type { DocumentParams, PositionParams, Preview,
|
|
9
|
-
export { blockAt, sectionAt } from './sections.ts'
|
|
10
|
-
export { anchorAt, directiveAt, fenceAt, typingAt, writtenOptions } from './syntax.ts'
|
|
11
|
-
export type { AnchorRef, Fence, Typing } from './syntax.ts'
|
|
6
|
+
export type { DocumentParams, PositionParams, Preview, SectionLocation } from './protocol.ts'
|
package/src/domain/messages.ts
CHANGED
|
@@ -9,13 +9,9 @@ export const LS_MESSAGES = {
|
|
|
9
9
|
timedOut: '超时',
|
|
10
10
|
exitCode: (code: number | null) => `退出码 ${code}`,
|
|
11
11
|
noOutput: '(没有输出)',
|
|
12
|
-
runsLive: '
|
|
12
|
+
runsLive: '*配置为在浏览器中执行,尚无录制输出。*',
|
|
13
13
|
notRecorded: '*还没有录制的输出。*',
|
|
14
14
|
moreLines: (count: number) => `…… 另有 ${count} 行`,
|
|
15
|
-
anchorScheme: '锚点类型',
|
|
16
|
-
options: '选项',
|
|
17
|
-
suggest: '提示',
|
|
18
|
-
builtIn: '内置',
|
|
19
15
|
},
|
|
20
16
|
'en': {
|
|
21
17
|
status: { exact: 'exact', moved: 'moved', fuzzy: 'to confirm', lost: 'lost' } as Record<string, string>,
|
|
@@ -25,13 +21,9 @@ export const LS_MESSAGES = {
|
|
|
25
21
|
timedOut: 'timed out',
|
|
26
22
|
exitCode: (code: number | null) => `exit code ${code}`,
|
|
27
23
|
noOutput: '(no output)',
|
|
28
|
-
runsLive: '*
|
|
24
|
+
runsLive: '*Configured to run in the browser; no recorded output.*',
|
|
29
25
|
notRecorded: '*No recorded output yet.*',
|
|
30
26
|
moreLines: (count: number) => `… ${count} more lines`,
|
|
31
|
-
anchorScheme: 'anchor scheme',
|
|
32
|
-
options: 'Options',
|
|
33
|
-
suggest: 'Suggest',
|
|
34
|
-
builtIn: 'built-in',
|
|
35
27
|
},
|
|
36
28
|
} satisfies Record<Locale, unknown>
|
|
37
29
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SectionLocation } from './protocol.ts'
|
|
2
2
|
|
|
3
3
|
const page = (link: string) => link.split('#')[0]!
|
|
4
4
|
|
|
5
|
-
export function previewUrl(base: string, section:
|
|
5
|
+
export function previewUrl(base: string, section: SectionLocation | null | undefined, shown?: SectionLocation): string {
|
|
6
6
|
const root = base.replace(/\/?$/, '/')
|
|
7
7
|
if (!section) return root
|
|
8
8
|
const id = encodeURIComponent(section.id)
|
package/src/domain/protocol.ts
CHANGED
|
@@ -15,7 +15,8 @@ export interface Preview {
|
|
|
15
15
|
|
|
16
16
|
export const SECTION_REQUEST = 'presmium/section'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** The section at a position in a source file, and its page in the web form. */
|
|
19
|
+
export interface SectionLocation {
|
|
19
20
|
entry: string
|
|
20
21
|
id: string
|
|
21
22
|
index: number
|
package/src/node/anchors.ts
CHANGED
|
@@ -1,54 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
1
|
+
import { resolve } from 'node:path'
|
|
3
2
|
import { pathToFileURL } from 'node:url'
|
|
4
3
|
import type { Compilation } from '@lincyaw/compiler'
|
|
5
|
-
import { isRenderable } from '@lincyaw/kernel'
|
|
4
|
+
import { docBlocks, isRenderable } from '@lincyaw/kernel'
|
|
6
5
|
import type { AnchorString, CodeNode, Resolution, ResolvedLocation } from '@lincyaw/kernel'
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
10
|
-
import type { AnchorRef, LsMessages } from '../domain/index.ts'
|
|
6
|
+
import { MarkupKind } from 'vscode-languageserver/node'
|
|
7
|
+
import type { Hover, Location, Range } from 'vscode-languageserver/node'
|
|
8
|
+
import type { LsMessages } from '../domain/index.ts'
|
|
11
9
|
|
|
12
|
-
const MAX_NAMES = 200
|
|
13
10
|
const MAX_LINES = 60
|
|
11
|
+
export interface AnchorRef { anchor: AnchorString, blockId: string, pin?: string, range: Range }
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
function resolveAnchor(compilation: Compilation, anchor: AnchorString): Promise<Resolution> {
|
|
18
|
-
return compilation.resolvers.resolve(anchor, compilation.src, compilation.lock.get(anchor))
|
|
13
|
+
function resolveAnchor(compilation: Compilation, ref: AnchorRef): Promise<Resolution> {
|
|
14
|
+
return compilation.resolvers.resolve(ref.anchor, compilation.src, compilation.lock.get(ref.anchor), ref.pin)
|
|
19
15
|
}
|
|
20
|
-
|
|
21
16
|
function locationOf(compilation: Compilation, location: ResolvedLocation): Location {
|
|
22
17
|
const last = location.text.split(/\r?\n/).at(-1) ?? ''
|
|
23
|
-
return {
|
|
24
|
-
|
|
25
|
-
range: { start: { line: location.startLine - 1, character: 0 }, end: { line: location.endLine - 1, character: last.length } },
|
|
26
|
-
}
|
|
18
|
+
return { uri: pathToFileURL(resolve(compilation.workspaceRoot, location.file)).href,
|
|
19
|
+
range: { start: { line: location.startLine - 1, character: 0 }, end: { line: location.endLine - 1, character: last.length } } }
|
|
27
20
|
}
|
|
28
|
-
|
|
29
21
|
export async function anchorDefinition(compilation: Compilation, ref: AnchorRef): Promise<Location[]> {
|
|
30
|
-
const resolution = await resolveAnchor(compilation, ref
|
|
31
|
-
|
|
32
|
-
return found.map(location => locationOf(compilation, location))
|
|
22
|
+
const resolution = await resolveAnchor(compilation, ref)
|
|
23
|
+
return (resolution.location ? [resolution.location] : resolution.candidates ?? []).map(location => locationOf(compilation, location))
|
|
33
24
|
}
|
|
34
|
-
|
|
35
25
|
function fenced(t: LsMessages, text: string, lang = ''): string {
|
|
36
26
|
const lines = text.replace(/\n+$/, '').split('\n')
|
|
37
27
|
const shown = lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), t.moreLines(lines.length - MAX_LINES)] : lines
|
|
38
28
|
const fence = '`'.repeat(Math.max(3, ...[...text.matchAll(/`+/g)].map(m => m[0].length + 1)))
|
|
39
29
|
return `${fence}${lang}\n${shown.join('\n')}\n${fence}`
|
|
40
30
|
}
|
|
41
|
-
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export async function anchorHover(compilation: Compilation, file: string, ref: AnchorRef, t: LsMessages): Promise<Hover> {
|
|
47
|
-
const range: Range = { start: { line: ref.line, character: ref.start }, end: { line: ref.line, character: ref.end } }
|
|
48
|
-
const block = blockAt(compilation.doc, file, ref.line + 1)
|
|
49
|
-
const node = block?.node.type === 'code' && block.node.origin?.anchor === ref.anchor ? block.node : undefined
|
|
31
|
+
function where(file: string, startLine: number, endLine: number): string { return `\`${file}:${startLine}-${endLine}\`` }
|
|
32
|
+
export async function anchorHover(compilation: Compilation, ref: AnchorRef, t: LsMessages): Promise<Hover> {
|
|
33
|
+
const block = [...docBlocks(compilation.doc)].find(({ block }) => block.id === ref.blockId)?.block
|
|
34
|
+
const node = block?.node.type === 'code' && block.node.origin?.anchor === ref.anchor && block.node.origin?.pin === ref.pin ? block.node : undefined
|
|
50
35
|
const parts = node?.origin && node.origin.startLine > 0 ? codeNodeHover(node, t) : await resolvedHover(compilation, ref, t)
|
|
51
|
-
return { contents: { kind: MarkupKind.Markdown, value: parts.join('\n\n') }, range }
|
|
36
|
+
return { contents: { kind: MarkupKind.Markdown, value: parts.join('\n\n') }, range: ref.range }
|
|
52
37
|
}
|
|
53
38
|
|
|
54
39
|
function codeNodeHover(node: CodeNode, t: LsMessages): string[] {
|
|
@@ -66,56 +51,10 @@ function codeNodeHover(node: CodeNode, t: LsMessages): string[] {
|
|
|
66
51
|
}
|
|
67
52
|
|
|
68
53
|
async function resolvedHover(compilation: Compilation, ref: AnchorRef, t: LsMessages): Promise<string[]> {
|
|
69
|
-
const resolution = await resolveAnchor(compilation, ref
|
|
54
|
+
const resolution = await resolveAnchor(compilation, ref)
|
|
70
55
|
const { location } = resolution
|
|
71
56
|
const parts = [[`**${ref.anchor}**`, location && where(location.file, location.startLine, location.endLine), `(${t.status[resolution.status] ?? resolution.status})`].filter(Boolean).join(' ')]
|
|
72
57
|
if (resolution.message) parts.push(resolution.message)
|
|
73
58
|
if (location && isRenderable(resolution)) parts.push(fenced(t, location.text, /\.([\w-]+)$/.exec(location.file)?.[1] ?? ''))
|
|
74
59
|
return parts
|
|
75
60
|
}
|
|
76
|
-
|
|
77
|
-
export async function anchorCompletion(compilation: Compilation, prefix: string, line: number, start: number, end: number, t: LsMessages): Promise<CompletionItem[]> {
|
|
78
|
-
const edit = (from: number, newText: string) => ({ range: { start: { line, character: from }, end: { line, character: end } }, newText })
|
|
79
|
-
const colon = prefix.indexOf(':')
|
|
80
|
-
if (colon < 0) {
|
|
81
|
-
return compilation.resolvers.schemes().map(scheme => ({ label: `${scheme}:`, kind: CompletionItemKind.Keyword, detail: t.anchorScheme, textEdit: edit(start, `${scheme}:`), command: suggest(t) }))
|
|
82
|
-
}
|
|
83
|
-
const scheme = prefix.slice(0, colon)
|
|
84
|
-
const target = prefix.slice(colon + 1)
|
|
85
|
-
const hash = target.lastIndexOf('#')
|
|
86
|
-
|
|
87
|
-
if (hash < 0) {
|
|
88
|
-
const dir = target.slice(0, target.lastIndexOf('/') + 1)
|
|
89
|
-
const path = resolve(compilation.workspaceRoot, dir)
|
|
90
|
-
const inside = relative(compilation.workspaceRoot, path)
|
|
91
|
-
if (inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return []
|
|
92
|
-
const entries = await readdir(path, { withFileTypes: true }).catch(() => [])
|
|
93
|
-
return entries.filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules').map((entry) => {
|
|
94
|
-
const name = entry.isDirectory() ? `${entry.name}/` : `${entry.name}#`
|
|
95
|
-
return { label: name, kind: entry.isDirectory() ? CompletionItemKind.Folder : CompletionItemKind.File, textEdit: edit(start + colon + 1 + dir.length, name), command: suggest(t) }
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const file = target.slice(0, hash)
|
|
100
|
-
const text = await compilation.src.read(file)
|
|
101
|
-
if (text === undefined) return []
|
|
102
|
-
const typed = target.slice(hash + 1).toLowerCase()
|
|
103
|
-
const harvested = (scheme === 'region' ? regionNames(text) : scheme === 'symbol' ? declarationNames(text) : [...regionNames(text), ...declarationNames(text)])
|
|
104
|
-
.filter(name => name.toLowerCase().includes(typed))
|
|
105
|
-
const resolved = await Promise.all(harvested.slice(0, MAX_NAMES).map(async (name) => {
|
|
106
|
-
const resolution = await compilation.resolvers.resolve(`${scheme}:${file}#${name}`, compilation.src)
|
|
107
|
-
return isRenderable(resolution) ? { name, location: resolution.location! } : undefined
|
|
108
|
-
}))
|
|
109
|
-
return resolved.filter(found => found !== undefined).map(({ name, location }) => ({
|
|
110
|
-
label: name,
|
|
111
|
-
kind: scheme === 'region' ? CompletionItemKind.Reference : CompletionItemKind.Function,
|
|
112
|
-
detail: `${location.file}:${location.startLine}-${location.endLine}`,
|
|
113
|
-
documentation: { kind: MarkupKind.Markdown, value: fenced(t, location.text) },
|
|
114
|
-
textEdit: edit(start + colon + 1 + hash + 1, name),
|
|
115
|
-
}))
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function unitNames(compilation: Compilation, anchor: AnchorString): Promise<string[]> {
|
|
119
|
-
const resolution = await resolveAnchor(compilation, anchor)
|
|
120
|
-
return resolution.location ? regionNames(resolution.location.text) : []
|
|
121
|
-
}
|
package/src/node/decks.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Compiler, DECK_ENTRY
|
|
1
|
+
import { basename, join } from 'node:path'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { Compiler, DECK_ENTRY } from '@lincyaw/compiler'
|
|
4
4
|
import type { Compilation } from '@lincyaw/compiler'
|
|
5
|
-
import { ANCHOR_LOCK_PATH, errorMessage } from '@lincyaw/kernel'
|
|
5
|
+
import { ANCHOR_LOCK_PATH, errorMessage, fnv1a64 } from '@lincyaw/kernel'
|
|
6
6
|
import type { Finding } from '@lincyaw/kernel'
|
|
7
|
+
import { documentCli } from './document-cli.ts'
|
|
7
8
|
|
|
8
9
|
export class Deck {
|
|
9
10
|
readonly entry: string
|
|
@@ -11,12 +12,15 @@ export class Deck {
|
|
|
11
12
|
readonly #compiled: (deck: Deck) => void
|
|
12
13
|
compilation?: Compilation
|
|
13
14
|
findings: Finding[] = []
|
|
15
|
+
sourceFingerprint?: string
|
|
14
16
|
#running?: Promise<void>
|
|
15
17
|
#again = false
|
|
18
|
+
#version = 0
|
|
19
|
+
#completedVersion = -1
|
|
16
20
|
|
|
17
21
|
constructor(entry: string, compiled: (deck: Deck) => void) {
|
|
18
22
|
this.entry = entry
|
|
19
|
-
this.#compiler = new Compiler(entry)
|
|
23
|
+
this.#compiler = new Compiler(entry, { bundled: documentCli(entry).bundled })
|
|
20
24
|
this.#compiled = compiled
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -39,45 +43,32 @@ export class Deck {
|
|
|
39
43
|
return this.#running ?? Promise.resolve()
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
46
|
+
invalidate(): void { this.#version++ }
|
|
47
|
+
get findingsCurrent(): boolean { return this.#completedVersion === this.#version }
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
dependsOn(file: string): boolean {
|
|
47
50
|
const { compilation } = this
|
|
48
51
|
if (!compilation) return file === this.entry
|
|
49
|
-
return compilation.
|
|
52
|
+
return compilation.sourceFiles.includes(file) || compilation.inputs.files.includes(file) || file === join(compilation.deckDir, ANCHOR_LOCK_PATH)
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
async #compileOnce(): Promise<void> {
|
|
56
|
+
const version = this.#version
|
|
57
|
+
let attemptedFingerprint: string | undefined
|
|
58
|
+
try { attemptedFingerprint = fnv1a64(readFileSync(this.entry, 'utf8')) } catch { /* The compiler reports unavailable entries. */ }
|
|
53
59
|
try {
|
|
54
60
|
const compilation = await this.#compiler.compile()
|
|
55
61
|
this.findings = [...compilation.doc.diagnostics, ...await compilation.check()]
|
|
56
62
|
this.compilation = compilation
|
|
63
|
+
this.sourceFingerprint = compilation.sourceFingerprints?.[this.entry]
|
|
57
64
|
} catch (error) {
|
|
65
|
+
this.compilation = undefined
|
|
66
|
+
this.sourceFingerprint = attemptedFingerprint
|
|
58
67
|
this.findings = [{ checkId: 'presmium.compile', severity: 'error', message: errorMessage(error), source: { file: this.entry, line: 1 } }]
|
|
59
68
|
}
|
|
60
|
-
this.#
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function declaresPlugins(file: string): boolean {
|
|
65
|
-
try {
|
|
66
|
-
return 'plugins' in readFrontmatter(readFileSync(file, 'utf8'))
|
|
67
|
-
} catch {
|
|
68
|
-
return false
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function candidateEntries(file: string): string[] {
|
|
73
|
-
const entries = basename(file) === DECK_ENTRY ? [file] : []
|
|
74
|
-
for (let dir = dirname(file); ; dir = dirname(dir)) {
|
|
75
|
-
const entry = join(dir, DECK_ENTRY)
|
|
76
|
-
if (entry !== file && existsSync(entry)) entries.push(entry)
|
|
77
|
-
if (dirname(dir) === dir) break
|
|
69
|
+
this.#completedVersion = version
|
|
70
|
+
if (this.findingsCurrent && !this.#again) this.#compiled(this)
|
|
78
71
|
}
|
|
79
|
-
if (basename(file) !== DECK_ENTRY && declaresPlugins(file)) entries.push(file)
|
|
80
|
-
return entries
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
export class Decks {
|
|
@@ -102,20 +93,11 @@ export class Decks {
|
|
|
102
93
|
return deck
|
|
103
94
|
}
|
|
104
95
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (deck.owns(file)) return deck
|
|
112
|
-
}
|
|
113
|
-
for (const entry of candidateEntries(file)) {
|
|
114
|
-
if (known.some(deck => deck.entry === entry)) continue
|
|
115
|
-
const deck = this.open(entry)
|
|
116
|
-
await deck.ready()
|
|
117
|
-
if (deck.owns(file)) return deck
|
|
118
|
-
}
|
|
119
|
-
return undefined
|
|
96
|
+
/** Only the canonical JSON entry opens a document; project inputs are watched dependencies. */
|
|
97
|
+
async findByFile(file: string): Promise<Deck | undefined> {
|
|
98
|
+
if (basename(file) !== DECK_ENTRY) return undefined
|
|
99
|
+
const deck = this.open(file)
|
|
100
|
+
await deck.ready()
|
|
101
|
+
return deck
|
|
120
102
|
}
|
|
121
103
|
}
|
|
@@ -1,38 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import type { ChildProcess } from 'node:child_process'
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
3
|
import { dirname, join } from 'node:path'
|
|
6
4
|
import { createInterface } from 'node:readline'
|
|
5
|
+
import { documentCli } from './document-cli.ts'
|
|
7
6
|
|
|
8
7
|
const START_TIMEOUT = 120_000
|
|
9
8
|
const PREVIEW_DIR = '.presmium/preview'
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function cliBin(from: string): string | undefined {
|
|
14
|
-
let dir: string
|
|
15
|
-
try {
|
|
16
|
-
dir = dirname(createRequire(from).resolve(CLI))
|
|
17
|
-
} catch {
|
|
18
|
-
return undefined
|
|
19
|
-
}
|
|
20
|
-
for (; dirname(dir) !== dir; dir = dirname(dir)) {
|
|
21
|
-
const file = join(dir, 'package.json')
|
|
22
|
-
if (!existsSync(file)) continue
|
|
23
|
-
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { name?: string, bin?: Record<string, string> }
|
|
24
|
-
if (pkg.name === CLI && pkg.bin?.presmium) return join(dir, pkg.bin.presmium)
|
|
25
|
-
}
|
|
26
|
-
return undefined
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function presmiumBin(deckDir: string): string {
|
|
30
|
-
const bin = cliBin(join(deckDir, 'package.json')) ?? cliBin(import.meta.url)
|
|
31
|
-
if (!bin) throw new Error(`${CLI} is not installed`)
|
|
32
|
-
return bin
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function stop(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
10
|
+
function stopProcess(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
36
11
|
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return
|
|
37
12
|
if (process.platform === 'win32') {
|
|
38
13
|
const task = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
@@ -40,7 +15,8 @@ function stop(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void {
|
|
|
40
15
|
} else child.kill(signal)
|
|
41
16
|
}
|
|
42
17
|
|
|
43
|
-
|
|
18
|
+
/** Starts a `presmium dev` web server for each deck the editor previews, once, and stops them all on close. */
|
|
19
|
+
export class DevServerLauncher {
|
|
44
20
|
readonly #urls = new Map<string, Promise<string>>()
|
|
45
21
|
readonly #children = new Set<ChildProcess>()
|
|
46
22
|
readonly #log: (line: string) => void
|
|
@@ -49,7 +25,8 @@ export class Previews {
|
|
|
49
25
|
this.#log = log
|
|
50
26
|
}
|
|
51
27
|
|
|
52
|
-
|
|
28
|
+
/** The address of the deck's dev server, started on first use. */
|
|
29
|
+
urlOf(entry: string): Promise<string> {
|
|
53
30
|
const known = this.#urls.get(entry)
|
|
54
31
|
if (known) return known
|
|
55
32
|
const forget = () => {
|
|
@@ -63,15 +40,16 @@ export class Previews {
|
|
|
63
40
|
|
|
64
41
|
#start(entry: string, forget: () => void): Promise<string> {
|
|
65
42
|
const deckDir = dirname(entry)
|
|
66
|
-
const args = [
|
|
67
|
-
|
|
43
|
+
const args = [documentCli(entry).bin, 'dev', entry, '--to', 'web', '--out', join(deckDir, PREVIEW_DIR)]
|
|
44
|
+
// The lines read below are the English ones: `presmium dev` prints in the system's language.
|
|
45
|
+
const child = spawn(process.execPath, args, { cwd: deckDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, LC_ALL: 'en_US.UTF-8' } })
|
|
68
46
|
this.#children.add(child)
|
|
69
47
|
return new Promise((resolve, reject) => {
|
|
70
48
|
let started = false
|
|
71
49
|
const fail = (error: Error) => {
|
|
72
50
|
clearTimeout(timer)
|
|
73
51
|
reject(error)
|
|
74
|
-
if (!started)
|
|
52
|
+
if (!started) stopProcess(child)
|
|
75
53
|
}
|
|
76
54
|
const timer = setTimeout(() => fail(new Error('presmium dev did not start the web server in time')), START_TIMEOUT)
|
|
77
55
|
createInterface({ input: child.stderr! }).on('line', line => this.#log(line))
|
|
@@ -99,11 +77,11 @@ export class Previews {
|
|
|
99
77
|
async close(): Promise<void> {
|
|
100
78
|
const exits = [...this.#children].map(child => new Promise<void>((done) => {
|
|
101
79
|
const timer = setTimeout(() => {
|
|
102
|
-
|
|
80
|
+
stopProcess(child, 'SIGKILL')
|
|
103
81
|
done()
|
|
104
82
|
}, 5000).unref()
|
|
105
83
|
child.once('exit', () => { clearTimeout(timer); done() })
|
|
106
|
-
|
|
84
|
+
stopProcess(child)
|
|
107
85
|
}))
|
|
108
86
|
this.#urls.clear()
|
|
109
87
|
await Promise.all(exits)
|
package/src/node/diagnostics.ts
CHANGED
|
@@ -1,38 +1,39 @@
|
|
|
1
|
-
import type { Finding, Severity } from '@lincyaw/kernel'
|
|
1
|
+
import type { Finding, Severity, SourcePos } from '@lincyaw/kernel'
|
|
2
|
+
import { printParseErrorCode } from 'jsonc-parser'
|
|
3
|
+
import { TextDocument } from 'vscode-languageserver-textdocument'
|
|
2
4
|
import { DiagnosticSeverity } from 'vscode-languageserver/node'
|
|
3
5
|
import type { Diagnostic, Range } from 'vscode-languageserver/node'
|
|
4
|
-
import {
|
|
6
|
+
import { nodeAtPointer, readDocument } from '../domain/document.ts'
|
|
5
7
|
|
|
6
|
-
const SEVERITY: Record<Severity, DiagnosticSeverity> = {
|
|
7
|
-
|
|
8
|
-
warning: DiagnosticSeverity.Warning,
|
|
9
|
-
info: DiagnosticSeverity.Information,
|
|
10
|
-
}
|
|
8
|
+
const SEVERITY: Record<Severity, DiagnosticSeverity> = { error: DiagnosticSeverity.Error, warning: DiagnosticSeverity.Warning, info: DiagnosticSeverity.Information }
|
|
9
|
+
export const offsetRange = (document: TextDocument, offset: number, length: number): Range => ({ start: document.positionAt(offset), end: document.positionAt(offset + Math.max(1, length)) })
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
/** The same syntax and graph validator used by source saves, before compilation is available. */
|
|
12
|
+
export function documentDiagnostics(text: string): Diagnostic[] {
|
|
13
|
+
const document = TextDocument.create('', 'json', 0, text), source = readDocument(text)
|
|
14
|
+
return [
|
|
15
|
+
...source.errors.map(error => ({ range: offsetRange(document, error.offset, error.length), severity: DiagnosticSeverity.Error, source: 'presmium', code: 'document.json', message: printParseErrorCode(error.error) })),
|
|
16
|
+
...(source.validation?.issues.map(issue => {
|
|
17
|
+
const node = nodeAtPointer(source.tree!, issue.path)
|
|
18
|
+
return { range: offsetRange(document, node.offset, node.length), severity: SEVERITY[issue.severity], source: 'presmium', code: issue.code, message: issue.message }
|
|
19
|
+
}) ?? []),
|
|
20
|
+
]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
23
|
+
/** Compiler JSON pointers resolve to real token ranges; project findings retain their code lines. */
|
|
24
|
+
export function diagnosticsByFile(findings: Finding[], entry: string, textOf: (file: string) => string): Map<string, Diagnostic[]> {
|
|
25
|
+
const byFile = new Map<string, Diagnostic[]>(), documents = new Map<string, TextDocument>()
|
|
26
|
+
const trees = new Map<string, ReturnType<typeof readDocument>['tree']>()
|
|
25
27
|
for (const finding of findings) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
28
|
+
const source = finding.source as SourcePos | undefined, file = source?.file ?? entry
|
|
29
|
+
let document = documents.get(file)
|
|
30
|
+
if (!document) { document = TextDocument.create('', 'json', 0, textOf(file)); documents.set(file, document) }
|
|
31
|
+
if (source?.pointer && !trees.has(file)) trees.set(file, readDocument(document.getText()).tree)
|
|
32
|
+
const tree = trees.get(file)
|
|
33
|
+
const node = tree && source?.pointer ? nodeAtPointer(tree, source.pointer) : undefined
|
|
34
|
+
const line = Math.max(1, source?.line ?? 1) - 1, text = document.getText().split(/\r?\n/)[line] ?? ''
|
|
35
|
+
const range = node ? offsetRange(document, node.offset, node.length) : { start: { line, character: text.length - text.trimStart().length }, end: { line, character: text.trimEnd().length } }
|
|
36
|
+
const diagnostic: Diagnostic = { range, severity: SEVERITY[finding.severity], source: 'presmium', code: finding.checkId, message: finding.hint ? `${finding.message}\n${finding.hint}` : finding.message }
|
|
36
37
|
byFile.set(file, [...byFile.get(file) ?? [], diagnostic])
|
|
37
38
|
}
|
|
38
39
|
return byFile
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { findPackageDir } from '@lincyaw/compiler'
|
|
4
|
+
import { findPackageBin } from '@lincyaw/kernel/node'
|
|
5
|
+
|
|
6
|
+
/** The document's installed command and the package that supplies its bundled plugins. */
|
|
7
|
+
export function documentCli(entry: string): { bin: string, bundled: string } {
|
|
8
|
+
const project = dirname(entry)
|
|
9
|
+
for (const [name, from] of [
|
|
10
|
+
['@lincyaw/presmium', project],
|
|
11
|
+
['@lincyaw/cli', project],
|
|
12
|
+
['@lincyaw/cli', dirname(fileURLToPath(import.meta.url))],
|
|
13
|
+
] as const) {
|
|
14
|
+
const bundled = findPackageDir(name, from)
|
|
15
|
+
const bin = findPackageBin(name, 'presmium', join(from, 'package.json'))
|
|
16
|
+
if (bundled && bin) return { bin, bundled }
|
|
17
|
+
}
|
|
18
|
+
throw new Error('@lincyaw/cli is not installed')
|
|
19
|
+
}
|
package/src/node/server.ts
CHANGED
|
@@ -1,134 +1,126 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename } from 'node:path'
|
|
2
3
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
3
|
-
import {
|
|
4
|
+
import { DOCUMENT_ENTRY, fnv1a64 } from '@lincyaw/kernel'
|
|
5
|
+
import type { AnchorString } from '@lincyaw/kernel'
|
|
6
|
+
import { sectionLinks, webPages } from '@lincyaw/web/domain'
|
|
7
|
+
import { findNodeAtLocation, getNodeValue } from 'jsonc-parser'
|
|
4
8
|
import { TextDocument } from 'vscode-languageserver-textdocument'
|
|
5
9
|
import { createConnection, DidChangeWatchedFilesNotification, ProposedFeatures, TextDocuments, TextDocumentSyncKind } from 'vscode-languageserver/node'
|
|
6
|
-
import {
|
|
7
|
-
import type { DocumentParams, PositionParams, Preview,
|
|
10
|
+
import { entityReference, LS_MESSAGES, nodeAt, readDocument, sectionAtPath, PREVIEW_REQUEST, SECTION_REQUEST } from '../domain/index.ts'
|
|
11
|
+
import type { DocumentParams, PositionParams, Preview, SectionLocation } from '../domain/index.ts'
|
|
8
12
|
import { anchorDefinition, anchorHover } from './anchors.ts'
|
|
9
|
-
import {
|
|
13
|
+
import type { AnchorRef } from './anchors.ts'
|
|
10
14
|
import { Decks } from './decks.ts'
|
|
11
15
|
import type { Deck } from './decks.ts'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { deckSyntax } from './syntax.ts'
|
|
16
|
+
import { DevServerLauncher } from './dev-server-launcher.ts'
|
|
17
|
+
import { diagnosticsByFile, documentDiagnostics, offsetRange } from './diagnostics.ts'
|
|
15
18
|
|
|
16
19
|
const COMPILE_DELAY = 50
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
20
|
+
const fileOf = (uri: string): string | undefined => uri.startsWith('file:') ? fileURLToPath(uri) : undefined
|
|
21
|
+
const canonical = (file: string | undefined): file is string => !!file && basename(file) === DOCUMENT_ENTRY
|
|
22
|
+
function fileText(file: string): string { try { return readFileSync(file, 'utf8') } catch { return '' } }
|
|
21
23
|
|
|
22
24
|
export function startServer(): void {
|
|
23
|
-
const connection = createConnection(ProposedFeatures.all)
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} catch {
|
|
34
|
-
return []
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const publish = (deck: Deck) => {
|
|
39
|
-
const byFile = toDiagnostics(deck.findings, deck.entry, fileLines)
|
|
40
|
-
const files = new Set([...deck.compilation?.files ?? [deck.entry], ...byFile.keys()])
|
|
25
|
+
const connection = createConnection(ProposedFeatures.all), documents = new TextDocuments(TextDocument)
|
|
26
|
+
const devServers = new DevServerLauncher(line => connection.console.log(line))
|
|
27
|
+
const published = new Map<Deck, Set<string>>(), timers = new Map<Deck, NodeJS.Timeout>()
|
|
28
|
+
const textOf = (file: string) => documents.get(pathToFileURL(file).href)?.getText() ?? fileText(file)
|
|
29
|
+
const publishDiagnostics = (deck: Deck) => {
|
|
30
|
+
const text = textOf(deck.entry), disk = fileText(deck.entry), source = documentDiagnostics(text)
|
|
31
|
+
const findings = !deck.findingsCurrent || deck.sourceFingerprint !== fnv1a64(disk) || source.some(d => d.severity === 1) || text.replace(/^\uFEFF/, '') !== disk.replace(/^\uFEFF/, '') ? [] : deck.findings
|
|
32
|
+
const byFile = diagnosticsByFile(findings, deck.entry, textOf)
|
|
33
|
+
byFile.set(deck.entry, [...source, ...byFile.get(deck.entry) ?? []])
|
|
34
|
+
const files = new Set([...deck.compilation?.sourceFiles ?? [deck.entry], ...byFile.keys()])
|
|
41
35
|
const uris = new Set([...files].map(file => pathToFileURL(file).href))
|
|
42
|
-
for (const file of files)
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const uri = pathToFileURL(file).href
|
|
38
|
+
void connection.sendDiagnostics({ uri, version: documents.get(uri)?.version, diagnostics: byFile.get(file) ?? [] })
|
|
45
39
|
}
|
|
40
|
+
for (const uri of published.get(deck) ?? []) if (!uris.has(uri)) void connection.sendDiagnostics({ uri, diagnostics: [] })
|
|
46
41
|
published.set(deck, uris)
|
|
47
42
|
}
|
|
48
|
-
const decks = new Decks(
|
|
49
|
-
|
|
43
|
+
const decks = new Decks(publishDiagnostics)
|
|
50
44
|
const changed = (files: string[]) => {
|
|
51
|
-
for (const deck of decks.all().filter(d => files.some(file => d.
|
|
45
|
+
for (const deck of decks.all().filter(d => files.some(file => d.dependsOn(file)))) {
|
|
46
|
+
deck.invalidate()
|
|
52
47
|
clearTimeout(timers.get(deck))
|
|
53
|
-
timers.set(deck, setTimeout(() => void deck.compile(), COMPILE_DELAY))
|
|
48
|
+
timers.set(deck, setTimeout(() => { timers.delete(deck); void deck.compile() }, COMPILE_DELAY))
|
|
54
49
|
}
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
const deckOf = async (uri: string) => {
|
|
58
|
-
const file = fileOf(uri)
|
|
59
|
-
const deck = file ? await decks.of(file) : undefined
|
|
60
|
-
return file && deck?.compilation ? { file, deck, compilation: deck.compilation } : undefined
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
let watchFiles = false
|
|
64
53
|
connection.onInitialize(({ capabilities }) => {
|
|
65
54
|
watchFiles = capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true
|
|
66
|
-
return {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
hoverProvider: true,
|
|
71
|
-
completionProvider: { triggerCharacters: ['{', ':', '#', '/', ','] },
|
|
72
|
-
},
|
|
73
|
-
serverInfo: { name: 'presmium-language-server' },
|
|
74
|
-
}
|
|
55
|
+
return { capabilities: {
|
|
56
|
+
textDocumentSync: { openClose: true, change: TextDocumentSyncKind.Incremental, save: { includeText: false } },
|
|
57
|
+
definitionProvider: true, hoverProvider: true,
|
|
58
|
+
}, serverInfo: { name: 'presmium-language-server' } }
|
|
75
59
|
})
|
|
76
60
|
connection.onInitialized(() => {
|
|
77
61
|
if (watchFiles) void connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [{ globPattern: '**/*' }] })
|
|
78
62
|
})
|
|
79
|
-
|
|
80
63
|
documents.onDidOpen(({ document }) => {
|
|
81
64
|
const file = fileOf(document.uri)
|
|
82
|
-
if (file)
|
|
65
|
+
if (!canonical(file)) return
|
|
66
|
+
void connection.sendDiagnostics({ uri: document.uri, version: document.version, diagnostics: documentDiagnostics(document.getText()) })
|
|
67
|
+
void decks.findByFile(file)
|
|
83
68
|
})
|
|
84
|
-
documents.
|
|
85
|
-
|
|
86
|
-
if (file) changed([file])
|
|
69
|
+
documents.onDidChangeContent(({ document }) => {
|
|
70
|
+
if (canonical(fileOf(document.uri))) void connection.sendDiagnostics({ uri: document.uri, version: document.version, diagnostics: documentDiagnostics(document.getText()) })
|
|
87
71
|
})
|
|
72
|
+
documents.onDidSave(({ document }) => { const file = fileOf(document.uri); if (canonical(file)) changed([file]) })
|
|
73
|
+
documents.onDidClose(({ document }) => { const deck = decks.all().find(deck => pathToFileURL(deck.entry).href === document.uri); if (deck) publishDiagnostics(deck) })
|
|
88
74
|
connection.onDidChangeWatchedFiles(({ changes }) => changed(changes.flatMap(change => fileOf(change.uri) ?? [])))
|
|
89
75
|
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
76
|
+
const sourceAt = (uri: string, position: { line: number, character: number }) => {
|
|
77
|
+
const file = fileOf(uri)
|
|
78
|
+
if (!canonical(file)) return undefined
|
|
79
|
+
const document = documents.get(uri) ?? TextDocument.create(uri, 'json', 0, fileText(file))
|
|
80
|
+
const source = readDocument(document.getText())
|
|
81
|
+
const found = source.tree && source.document ? nodeAt(source.tree, document.offsetAt(position)) : undefined
|
|
82
|
+
return found && source.document ? { ...found, document, file, tree: source.tree!, content: source.document } : undefined
|
|
83
|
+
}
|
|
84
|
+
const anchorOf = async (uri: string, position: { line: number, character: number }) => {
|
|
85
|
+
const found = sourceAt(uri, position)
|
|
86
|
+
if (!found || found.path[0] !== 'blocks' || found.path.at(-1) !== 'anchor' || found.path.at(-2) !== 'origin' || found.node.type !== 'string') return undefined
|
|
87
|
+
const block = found.content.blocks[String(found.path[1])]
|
|
88
|
+
if (block?.type !== 'code') return undefined
|
|
89
|
+
const deck = await decks.findByFile(found.file)
|
|
90
|
+
if (!deck?.compilation) return undefined
|
|
91
|
+
const origin = findNodeAtLocation(found.tree, found.path.slice(0, -1)), pin = origin && getNodeValue(origin)?.pin
|
|
92
|
+
const ref: AnchorRef = { anchor: found.node.value as AnchorString, blockId: block.id, range: offsetRange(found.document, found.node.offset + 1, found.node.length - 2), ...typeof pin === 'string' ? { pin } : {} }
|
|
93
|
+
return { compilation: deck.compilation, ref, t: LS_MESSAGES[found.content.metadata.lang === 'en' ? 'en' : 'zh-CN'] }
|
|
96
94
|
}
|
|
97
|
-
|
|
98
95
|
connection.onDefinition(async ({ textDocument, position }) => {
|
|
99
|
-
const
|
|
100
|
-
|
|
96
|
+
const anchor = await anchorOf(textDocument.uri, position)
|
|
97
|
+
if (anchor) return anchorDefinition(anchor.compilation, anchor.ref)
|
|
98
|
+
const found = sourceAt(textDocument.uri, position)
|
|
99
|
+
const target = found && entityReference(found.content, found.path, found.node.value)
|
|
100
|
+
const node = target && found ? findNodeAtLocation(found.tree, target) : undefined
|
|
101
|
+
return node && found ? [{ uri: textDocument.uri, range: offsetRange(found.document, node.offset, node.length) }] : null
|
|
101
102
|
})
|
|
102
|
-
|
|
103
103
|
connection.onHover(async ({ textDocument, position }) => {
|
|
104
|
-
const found = await anchorOf(textDocument.uri, position
|
|
105
|
-
return found ? anchorHover(found.compilation, found.
|
|
104
|
+
const found = await anchorOf(textDocument.uri, position)
|
|
105
|
+
return found ? anchorHover(found.compilation, found.ref, found.t) : null
|
|
106
106
|
})
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const doc = found?.compilation.doc
|
|
116
|
-
const section = doc && sectionAt(doc, found.file, position.line + 1)
|
|
117
|
-
if (!section) return null
|
|
118
|
-
const link = sectionLinks(toPages(doc))[section.id] ?? '/'
|
|
119
|
-
return { entry: found.deck.entry, id: section.id, index: section.index, title: section.title, link }
|
|
107
|
+
connection.onRequest(SECTION_REQUEST, async ({ textDocument, position }: PositionParams): Promise<SectionLocation | null> => {
|
|
108
|
+
const found = sourceAt(textDocument.uri, position)
|
|
109
|
+
const id = found && sectionAtPath(found.content, found.path)
|
|
110
|
+
const section = id && found?.content.sections[id]
|
|
111
|
+
if (!found || !id || !section) return null
|
|
112
|
+
const deck = await decks.findByFile(found.file)
|
|
113
|
+
const link = deck?.compilation ? sectionLinks(webPages(deck.compilation.doc))[id] ?? '/' : '/'
|
|
114
|
+
return { entry: found.file, id, index: found.content.sectionOrder.indexOf(id) + 1, title: section.title, link }
|
|
120
115
|
})
|
|
121
|
-
|
|
122
116
|
connection.onRequest(PREVIEW_REQUEST, async ({ textDocument }: DocumentParams): Promise<Preview> => {
|
|
123
|
-
const file = fileOf(textDocument.uri)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return { entry: deck.entry, url: await previews.open(deck.entry) }
|
|
117
|
+
const file = fileOf(textDocument.uri), deck = canonical(file) ? await decks.findByFile(file) : undefined
|
|
118
|
+
if (!deck) throw new Error(`Preview requires a canonical ${DOCUMENT_ENTRY} document.`)
|
|
119
|
+
return { entry: deck.entry, url: await devServers.urlOf(deck.entry) }
|
|
127
120
|
})
|
|
128
121
|
|
|
129
|
-
connection.onShutdown(() =>
|
|
130
|
-
process.once('exit', () => void
|
|
131
|
-
|
|
122
|
+
connection.onShutdown(() => { for (const timer of timers.values()) clearTimeout(timer); timers.clear(); return devServers.close() })
|
|
123
|
+
process.once('exit', () => void devServers.close())
|
|
132
124
|
documents.listen(connection)
|
|
133
125
|
connection.listen()
|
|
134
126
|
}
|
package/src/domain/directives.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { DirectiveOptionType, DirectiveSpec } from '@lincyaw/kernel'
|
|
2
|
-
|
|
3
|
-
export interface OptionInfo {
|
|
4
|
-
type?: DirectiveOptionType
|
|
5
|
-
doc?: string
|
|
6
|
-
values?: string[]
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface DirectiveInfo {
|
|
10
|
-
name: string
|
|
11
|
-
source: string
|
|
12
|
-
doc?: string
|
|
13
|
-
options: Record<string, OptionInfo>
|
|
14
|
-
anchor?: boolean
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function directiveInfos(builtin: readonly DirectiveInfo[], plugins: { plugin: string, directives: readonly DirectiveSpec[] }[], run?: OptionInfo): Map<string, DirectiveInfo> {
|
|
18
|
-
const infos = new Map(builtin.map(info => [info.name, info]))
|
|
19
|
-
for (const { plugin, directives } of plugins) {
|
|
20
|
-
for (const spec of directives) {
|
|
21
|
-
if (infos.has(spec.name)) continue
|
|
22
|
-
const options = Object.fromEntries(Object.entries(spec.options ?? {}).map(([name, type]): [string, OptionInfo] => [name, name === 'run' && run ? { ...run, type } : { type }]))
|
|
23
|
-
infos.set(spec.name, { name: spec.name, source: plugin, options, ...spec.arg === 'anchor' ? { anchor: true } : {} })
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return infos
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function optionsOf(info: DirectiveInfo, common: Record<string, OptionInfo>): Record<string, OptionInfo> {
|
|
30
|
-
return { ...common, ...info.options }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function anchorDirectives(infos: Map<string, DirectiveInfo>): Set<string> {
|
|
34
|
-
return new Set([...infos.values()].filter(info => info.anchor).map(info => info.name))
|
|
35
|
-
}
|
package/src/domain/names.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
const REGION = /(?<![A-Za-z])#?region\b[ \t]*(.*?)[ \t]*(?:-->|\*\/|\*\))?[ \t]*$/i
|
|
2
|
-
|
|
3
|
-
export function regionNames(text: string): string[] {
|
|
4
|
-
const names = text.split(/\r?\n/).map(line => REGION.exec(line)?.[1] ?? '').filter(Boolean)
|
|
5
|
-
return [...new Set(names)]
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const DECLARATION = /\b(class|def|function\*?|interface|type|enum|namespace|const|let|var)\s+([A-Za-z_$][\w$]*)/
|
|
9
|
-
const MEMBER = /^\s+(?:(?:export|public|private|protected|static|readonly|async|abstract|override|declare|get|set|def)\s+)*\*?([A-Za-z_$][\w$]*)\s*[(<:=?!]/
|
|
10
|
-
const ASSIGNMENT = /^([A-Za-z_]\w*)\s*(?::[^=]*)?=(?!=)/
|
|
11
|
-
const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'else', 'elif', 'with', 'try', 'new', 'await', 'yield', 'throw', 'print', 'self', 'this', 'super'])
|
|
12
|
-
|
|
13
|
-
export function declarationNames(text: string): string[] {
|
|
14
|
-
const names = new Set<string>()
|
|
15
|
-
const classes: { name: string, indent: number }[] = []
|
|
16
|
-
for (const line of text.split(/\r?\n/)) {
|
|
17
|
-
if (!line.trim()) continue
|
|
18
|
-
const indent = /^[ \t]*/.exec(line)![0].length
|
|
19
|
-
while (classes.length > 0 && indent <= classes.at(-1)!.indent) classes.pop()
|
|
20
|
-
const declared = DECLARATION.exec(line)
|
|
21
|
-
const name = declared?.[2] ?? (classes.length > 0 ? MEMBER.exec(line)?.[1] : ASSIGNMENT.exec(line)?.[1])
|
|
22
|
-
if (!name || KEYWORDS.has(name)) continue
|
|
23
|
-
names.add([...classes.map(c => c.name), name].join('.'))
|
|
24
|
-
if (declared?.[1] === 'class') classes.push({ name, indent })
|
|
25
|
-
}
|
|
26
|
-
return [...names]
|
|
27
|
-
}
|
package/src/domain/sections.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { irBlocks } from '@lincyaw/kernel'
|
|
2
|
-
import type { IRBlock, IRDocument, IRSection } from '@lincyaw/kernel'
|
|
3
|
-
|
|
4
|
-
export function sectionAt(doc: IRDocument, file: string, line: number): IRSection | undefined {
|
|
5
|
-
const headed = doc.sections.filter(s => s.source.file === file && s.source.line <= line).at(-1)
|
|
6
|
-
if (headed) return headed
|
|
7
|
-
let first: IRSection | undefined
|
|
8
|
-
let last: { section: IRSection, line: number } | undefined
|
|
9
|
-
for (const section of doc.sections) {
|
|
10
|
-
for (const block of irBlocks(section.blocks)) {
|
|
11
|
-
if (block.source.file !== file) continue
|
|
12
|
-
first ??= section
|
|
13
|
-
if (block.source.line <= line && block.source.line >= (last?.line ?? 0)) last = { section, line: block.source.line }
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return last?.section ?? first ?? (file === doc.meta.entry ? doc.sections[0] : undefined)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function blockAt(doc: IRDocument, file: string, line: number): IRBlock | undefined {
|
|
20
|
-
for (const section of doc.sections) {
|
|
21
|
-
for (const block of irBlocks(section.blocks)) {
|
|
22
|
-
if (block.source.file === file && block.source.line === line) return block
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return undefined
|
|
26
|
-
}
|
package/src/domain/syntax.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
export interface Fence {
|
|
2
|
-
line: number
|
|
3
|
-
name: string
|
|
4
|
-
nameStart: number
|
|
5
|
-
arg: string
|
|
6
|
-
argStart: number
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const FENCE = /^([ \t]*(?:`{3,}|~{3,}|:{3,})\{)([^{}\s]*)\}(.*)$/
|
|
10
|
-
const OPTION = /^[ \t]*:[\w-]*(?::.*)?$/
|
|
11
|
-
|
|
12
|
-
export function fenceAt(lines: readonly string[], line: number): Fence | undefined {
|
|
13
|
-
const match = FENCE.exec(lines[line] ?? '')
|
|
14
|
-
if (!match) return undefined
|
|
15
|
-
const [, open, name, rest] = match as unknown as [string, string, string, string]
|
|
16
|
-
const after = open.length + name.length + 1
|
|
17
|
-
return { line, name, nameStart: open.length, arg: rest.trim(), argStart: after + rest.length - rest.trimStart().length }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function directiveAt(lines: readonly string[], line: number): Fence | undefined {
|
|
21
|
-
for (let i = line; i >= 0; i--) {
|
|
22
|
-
const fence = fenceAt(lines, i)
|
|
23
|
-
if (fence) return fence
|
|
24
|
-
if (!OPTION.test(lines[i] ?? '')) return undefined
|
|
25
|
-
}
|
|
26
|
-
return undefined
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function writtenOptions(lines: readonly string[], fence: Fence): string[] {
|
|
30
|
-
const names: string[] = []
|
|
31
|
-
for (let i = fence.line + 1; i < lines.length && OPTION.test(lines[i]!); i++) {
|
|
32
|
-
const name = /^[ \t]*:([\w-]+):/.exec(lines[i]!)?.[1]
|
|
33
|
-
if (name) names.push(name)
|
|
34
|
-
}
|
|
35
|
-
return names
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface AnchorRef {
|
|
39
|
-
anchor: `${string}:${string}`
|
|
40
|
-
scheme: string
|
|
41
|
-
line: number
|
|
42
|
-
start: number
|
|
43
|
-
end: number
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function anchorAt(lines: readonly string[], line: number, schemes: readonly string[] = [], anchors: ReadonlySet<string> = new Set()): AnchorRef | undefined {
|
|
47
|
-
const fence = fenceAt(lines, line)
|
|
48
|
-
const word = (fence && anchors.has(fence.name) ? fence.arg : fence?.arg.split(/\s/)[0]) ?? ''
|
|
49
|
-
const colon = word.indexOf(':')
|
|
50
|
-
if (!fence || colon <= 0) return undefined
|
|
51
|
-
const scheme = word.slice(0, colon)
|
|
52
|
-
if (!anchors.has(fence.name) && !schemes.includes(scheme)) return undefined
|
|
53
|
-
return { anchor: word as AnchorRef['anchor'], scheme, line, start: fence.argStart, end: fence.argStart + word.length }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export type Typing =
|
|
57
|
-
| { kind: 'directive', prefix: string, start: number, closed: boolean }
|
|
58
|
-
| { kind: 'argument', fence: Fence, prefix: string, start: number }
|
|
59
|
-
| { kind: 'option', fence: Fence, prefix: string, start: number, written: string[] }
|
|
60
|
-
| { kind: 'value', fence: Fence, option: string, prefix: string, start: number }
|
|
61
|
-
|
|
62
|
-
export function typingAt(lines: readonly string[], line: number, character: number): Typing | undefined {
|
|
63
|
-
const text = lines[line] ?? ''
|
|
64
|
-
const before = text.slice(0, character)
|
|
65
|
-
const opening = /^[ \t]*(?:`{3,}|~{3,}|:{3,})\{([\w-]*)$/.exec(before)
|
|
66
|
-
if (opening) return { kind: 'directive', prefix: opening[1]!, start: character - opening[1]!.length, closed: text[character] === '}' }
|
|
67
|
-
|
|
68
|
-
const fence = fenceAt(lines, line)
|
|
69
|
-
if (fence) {
|
|
70
|
-
const brace = fence.nameStart + fence.name.length
|
|
71
|
-
if (character <= brace) return undefined
|
|
72
|
-
const prefix = before.slice(brace + 1).trimStart()
|
|
73
|
-
return { kind: 'argument', fence, prefix, start: character - prefix.length }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const directive = directiveAt(lines, line)
|
|
77
|
-
if (!directive) return undefined
|
|
78
|
-
const option = /^[ \t]*:([\w-]*)$/.exec(before)
|
|
79
|
-
if (option) return { kind: 'option', fence: directive, prefix: option[1]!, start: character - option[1]!.length, written: writtenOptions(lines, directive) }
|
|
80
|
-
const value = /^[ \t]*:([\w-]+):[ \t]*(.*)$/.exec(before)
|
|
81
|
-
if (!value) return undefined
|
|
82
|
-
const prefix = value[2]!.split(',').at(-1)!.trimStart()
|
|
83
|
-
return { kind: 'value', fence: directive, option: value[1]!, prefix, start: character - prefix.length }
|
|
84
|
-
}
|
package/src/node/completion.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type { Compilation } from '@lincyaw/compiler'
|
|
2
|
-
import { CompletionItemKind, MarkupKind } from 'vscode-languageserver/node'
|
|
3
|
-
import type { CompletionItem, Position } from 'vscode-languageserver/node'
|
|
4
|
-
import { anchorAt, optionsOf, typingAt } from '../domain/index.ts'
|
|
5
|
-
import type { DirectiveInfo, Fence } from '../domain/index.ts'
|
|
6
|
-
import { anchorCompletion, unitNames } from './anchors.ts'
|
|
7
|
-
import { deckSyntax } from './syntax.ts'
|
|
8
|
-
import type { DeckSyntax } from './syntax.ts'
|
|
9
|
-
|
|
10
|
-
function describe(info: DirectiveInfo, syntax: DeckSyntax): string {
|
|
11
|
-
const options = Object.keys(optionsOf(info, syntax.common)).map(name => `\`:${name}:\``).join(', ')
|
|
12
|
-
return [info.doc, `${syntax.t.options}: ${options}`].filter(Boolean).join('\n\n')
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function isAnchorArgument(fence: Fence, prefix: string, compilation: Compilation, syntax: DeckSyntax): boolean {
|
|
16
|
-
return syntax.anchors.has(fence.name) || compilation.resolvers.schemes().some(scheme => prefix.startsWith(`${scheme}:`))
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function complete(compilation: Compilation, lines: string[], position: Position): Promise<CompletionItem[]> {
|
|
20
|
-
const typing = typingAt(lines, position.line, position.character)
|
|
21
|
-
if (!typing) return []
|
|
22
|
-
const { line, character } = position
|
|
23
|
-
const edit = (newText: string) => ({ range: { start: { line, character: typing.start }, end: { line, character } }, newText })
|
|
24
|
-
const syntax = deckSyntax(compilation)
|
|
25
|
-
const infos = syntax.directives
|
|
26
|
-
|
|
27
|
-
switch (typing.kind) {
|
|
28
|
-
case 'directive':
|
|
29
|
-
return [...infos.values()].map(info => ({
|
|
30
|
-
label: info.name,
|
|
31
|
-
kind: CompletionItemKind.Keyword,
|
|
32
|
-
detail: info.source,
|
|
33
|
-
documentation: { kind: MarkupKind.Markdown, value: describe(info, syntax) },
|
|
34
|
-
textEdit: edit(typing.closed ? info.name : `${info.name}}`),
|
|
35
|
-
}))
|
|
36
|
-
|
|
37
|
-
case 'argument': {
|
|
38
|
-
if (typing.fence.name === 'component') {
|
|
39
|
-
return [...compilation.components].map(([name, plugin]) => ({ label: name, kind: CompletionItemKind.Class, detail: plugin, textEdit: edit(name) }))
|
|
40
|
-
}
|
|
41
|
-
if (!isAnchorArgument(typing.fence, typing.prefix, compilation, syntax)) return []
|
|
42
|
-
return anchorCompletion(compilation, typing.prefix, line, typing.start, character, syntax.t)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
case 'option': {
|
|
46
|
-
const info = infos.get(typing.fence.name)
|
|
47
|
-
if (!info) return []
|
|
48
|
-
return Object.entries(optionsOf(info, syntax.common)).filter(([name]) => !typing.written.includes(name)).map(([name, option]) => ({
|
|
49
|
-
label: name,
|
|
50
|
-
kind: CompletionItemKind.Property,
|
|
51
|
-
detail: option.type,
|
|
52
|
-
documentation: option.doc,
|
|
53
|
-
textEdit: edit(`${name}: `),
|
|
54
|
-
}))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
case 'value': {
|
|
58
|
-
const info = infos.get(typing.fence.name)
|
|
59
|
-
const values = [...(info && optionsOf(info, syntax.common)[typing.option]?.values) ?? []]
|
|
60
|
-
if (typing.option === 'executor') values.push(...compilation.plugins.flatMap(({ plugin }) => plugin?.executors?.map(e => e.id) ?? []))
|
|
61
|
-
const anchor = typing.option === 'steps' ? anchorAt(lines, typing.fence.line, compilation.resolvers.schemes(), syntax.anchors) : undefined
|
|
62
|
-
if (anchor) values.push(...await unitNames(compilation, anchor.anchor), 'all', 'none')
|
|
63
|
-
return [...new Set(values)].map(value => ({ label: value, kind: CompletionItemKind.Value, textEdit: edit(value) }))
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
package/src/node/syntax.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { BUILTIN_DIRECTIVE_INFO, COMMON_DIRECTIVE_OPTIONS, RUN_OPTION } from '@lincyaw/compiler'
|
|
2
|
-
import type { Compilation, DirectiveOptionInfo } from '@lincyaw/compiler'
|
|
3
|
-
import { deckLocale } from '@lincyaw/kernel'
|
|
4
|
-
import type { Locale } from '@lincyaw/kernel'
|
|
5
|
-
import { anchorDirectives, directiveInfos, LS_MESSAGES } from '../domain/index.ts'
|
|
6
|
-
import type { DirectiveInfo, LsMessages, OptionInfo } from '../domain/index.ts'
|
|
7
|
-
|
|
8
|
-
export interface DeckSyntax {
|
|
9
|
-
directives: Map<string, DirectiveInfo>
|
|
10
|
-
common: Record<string, OptionInfo>
|
|
11
|
-
anchors: Set<string>
|
|
12
|
-
t: LsMessages
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function option(info: DirectiveOptionInfo, locale: Locale): OptionInfo {
|
|
16
|
-
return { ...info.type ? { type: info.type } : {}, ...info.doc ? { doc: info.doc[locale] } : {}, ...info.values ? { values: info.values } : {} }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function options(infos: Record<string, DirectiveOptionInfo>, locale: Locale): Record<string, OptionInfo> {
|
|
20
|
-
return Object.fromEntries(Object.entries(infos).map(([name, info]) => [name, option(info, locale)]))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function deckSyntax(compilation: Compilation): DeckSyntax {
|
|
24
|
-
const locale = deckLocale(compilation.doc.meta.lang)
|
|
25
|
-
const t = LS_MESSAGES[locale]
|
|
26
|
-
const builtin = BUILTIN_DIRECTIVE_INFO.map(info => ({ name: info.name, source: t.builtIn, doc: info.doc[locale], options: options(info.options, locale) }))
|
|
27
|
-
const plugins = compilation.plugins.flatMap(({ plugin }) => plugin ? [{ plugin: plugin.id, directives: plugin.directives ?? [] }] : [])
|
|
28
|
-
const directives = directiveInfos(builtin, plugins, option(RUN_OPTION, locale))
|
|
29
|
-
return { directives, common: options(COMMON_DIRECTIVE_OPTIONS, locale), anchors: anchorDirectives(directives), t }
|
|
30
|
-
}
|