@lincyaw/language-server 1.0.0-rc.0 → 1.0.0-rc.1

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @lincyaw/language-server
2
2
 
3
- The language server of Presmium documents: diagnostics, anchor definitions and hovers, completion of directives, options and anchors, and the preview that follows the cursor.
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 turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
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 starts it; other editors start it as a language server for Markdown files.
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.en.md)
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.0",
4
- "description": "Language server for Presmium documents: diagnostics, anchor definitions and hovers, completion and preview sync",
3
+ "version": "1.0.0-rc.1",
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.0",
42
- "@lincyaw/kernel": "1.0.0-rc.0",
43
- "@lincyaw/compiler": "1.0.0-rc.0",
44
- "@lincyaw/web": "1.0.0-rc.0",
45
- "@lincyaw/host": "1.0.0-rc.0",
46
- "@lincyaw/viewer": "1.0.0-rc.0"
42
+ "@lincyaw/cli": "1.0.0-rc.1",
43
+ "@lincyaw/compiler": "1.0.0-rc.1",
44
+ "@lincyaw/kernel": "1.0.0-rc.1",
45
+ "@lincyaw/viewer": "1.0.0-rc.1",
46
+ "@lincyaw/host": "1.0.0-rc.1",
47
+ "@lincyaw/web": "1.0.0-rc.1"
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
+ }
@@ -1,11 +1,6 @@
1
- export { anchorDirectives, directiveInfos, optionsOf } from './directives.ts'
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 { declarationNames, regionNames } from './names.ts'
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, SectionAt } from './protocol.ts'
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'
@@ -9,13 +9,9 @@ export const LS_MESSAGES = {
9
9
  timedOut: '超时',
10
10
  exitCode: (code: number | null) => `退出码 ${code}`,
11
11
  noOutput: '(没有输出)',
12
- runsLive: '*在浏览器中执行(`:run: live`),没有录制的输出。*',
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: '*Runs in the browser (`:run: live`); no recorded output.*',
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 { SectionAt } from './protocol.ts'
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: SectionAt | null | undefined, shown?: SectionAt): string {
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)
@@ -15,7 +15,8 @@ export interface Preview {
15
15
 
16
16
  export const SECTION_REQUEST = 'presmium/section'
17
17
 
18
- export interface SectionAt {
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
@@ -1,54 +1,39 @@
1
- import { readdir } from 'node:fs/promises'
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 { CompletionItemKind, MarkupKind } from 'vscode-languageserver/node'
8
- import type { CompletionItem, Hover, Location, Range } from 'vscode-languageserver/node'
9
- import { blockAt, declarationNames, regionNames } from '../domain/index.ts'
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
- const suggest = (t: LsMessages) => ({ title: t.suggest, command: 'editor.action.triggerSuggest' })
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
- uri: pathToFileURL(resolve(compilation.workspaceRoot, location.file)).href,
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.anchor)
31
- const found = resolution.location ? [resolution.location] : resolution.candidates ?? []
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 where(file: string, startLine: number, endLine: number): string {
43
- return `\`${file}:${startLine}-${endLine}\``
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.anchor)
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 { existsSync, readFileSync } from 'node:fs'
2
- import { basename, dirname, join } from 'node:path'
3
- import { Compiler, DECK_ENTRY, readFrontmatter } from '@lincyaw/compiler'
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
- owns(file: string): boolean {
43
- return this.compilation ? this.compilation.files.includes(file) : file === this.entry
44
- }
46
+ invalidate(): void { this.#version++ }
47
+ get findingsCurrent(): boolean { return this.#completedVersion === this.#version }
45
48
 
46
- reads(file: string): boolean {
49
+ dependsOn(file: string): boolean {
47
50
  const { compilation } = this
48
51
  if (!compilation) return file === this.entry
49
- return compilation.files.includes(file) || compilation.dependencies.files.includes(file) || file === join(compilation.deckDir, ANCHOR_LOCK_PATH)
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.#compiled(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
- async of(file: string): Promise<Deck | undefined> {
106
- const known = this.all()
107
- const owner = known.find(deck => deck.compilation?.files.includes(file))
108
- if (owner) return owner
109
- for (const deck of known) {
110
- await deck.ready()
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
- const CLI = '@lincyaw/cli'
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
- export class Previews {
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
- open(entry: string): Promise<string> {
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 = [presmiumBin(deckDir), 'dev', entry, '--to', 'web', '--out', join(deckDir, PREVIEW_DIR)]
67
- const child = spawn(process.execPath, args, { cwd: deckDir, stdio: ['ignore', 'pipe', 'pipe'] })
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) stop(child)
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
- stop(child, 'SIGKILL')
80
+ stopProcess(child, 'SIGKILL')
103
81
  done()
104
82
  }, 5000).unref()
105
83
  child.once('exit', () => { clearTimeout(timer); done() })
106
- stop(child)
84
+ stopProcess(child)
107
85
  }))
108
86
  this.#urls.clear()
109
87
  await Promise.all(exits)
@@ -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 { fenceAt } from '../domain/index.ts'
6
+ import { nodeAtPointer, readDocument } from '../domain/document.ts'
5
7
 
6
- const SEVERITY: Record<Severity, DiagnosticSeverity> = {
7
- error: DiagnosticSeverity.Error,
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
- function rangeOf(text: string, line: number, message: string): Range {
13
- const fence = fenceAt([text], 0)
14
- const word = fence?.arg.split(/\s/)[0]
15
- if (fence && word && message.includes(word)) {
16
- return { start: { line, character: fence.argStart }, end: { line, character: fence.argStart + word.length } }
17
- }
18
- const start = text.length - text.trimStart().length
19
- return { start: { line, character: start }, end: { line, character: text.trimEnd().length } }
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
- export function toDiagnostics(findings: Finding[], entry: string, lines: (file: string) => string[]): Map<string, Diagnostic[]> {
23
- const byFile = new Map<string, Diagnostic[]>()
24
- const texts = new Map<string, string[]>()
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 file = finding.source?.file ?? entry
27
- const line = Math.max(1, finding.source?.line ?? 1) - 1
28
- if (!texts.has(file)) texts.set(file, lines(file))
29
- const diagnostic: Diagnostic = {
30
- range: rangeOf(texts.get(file)![line] ?? '', line, finding.message),
31
- severity: SEVERITY[finding.severity],
32
- source: 'presmium',
33
- code: finding.checkId,
34
- message: finding.hint ? `${finding.message}\n${finding.hint}` : finding.message,
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
+ }
@@ -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 { sectionLinks, toPages } from '@lincyaw/web/domain'
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 { anchorAt, PREVIEW_REQUEST, SECTION_REQUEST, sectionAt } from '../domain/index.ts'
7
- import type { DocumentParams, PositionParams, Preview, SectionAt } from '../domain/index.ts'
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 { complete } from './completion.ts'
13
+ import type { AnchorRef } from './anchors.ts'
10
14
  import { Decks } from './decks.ts'
11
15
  import type { Deck } from './decks.ts'
12
- import { toDiagnostics } from './diagnostics.ts'
13
- import { Previews } from './preview.ts'
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
- function fileOf(uri: string): string | undefined {
19
- return uri.startsWith('file:') ? fileURLToPath(uri) : undefined
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 documents = new TextDocuments(TextDocument)
25
- const previews = new Previews(line => connection.console.log(line))
26
- const published = new Map<Deck, Set<string>>()
27
- const timers = new Map<Deck, NodeJS.Timeout>()
28
-
29
- const bufferLines = (uri: string) => documents.get(uri)?.getText().split(/\r?\n/) ?? []
30
- const fileLines = (file: string): string[] => {
31
- try {
32
- return readFileSync(file, 'utf8').split(/\r?\n/)
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) void connection.sendDiagnostics({ uri: pathToFileURL(file).href, diagnostics: byFile.get(file) ?? [] })
43
- for (const uri of published.get(deck) ?? []) {
44
- if (!uris.has(uri)) void connection.sendDiagnostics({ uri, diagnostics: [] })
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(publish)
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.reads(file)))) {
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
- capabilities: {
68
- textDocumentSync: { openClose: true, change: TextDocumentSyncKind.Incremental, save: { includeText: false } },
69
- definitionProvider: true,
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) void decks.of(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.onDidSave(({ document }) => {
85
- const file = fileOf(document.uri)
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 anchorOf = async (uri: string, line: number) => {
91
- const found = await deckOf(uri)
92
- if (!found) return undefined
93
- const syntax = deckSyntax(found.compilation)
94
- const ref = anchorAt(bufferLines(uri), line, found.compilation.resolvers.schemes(), syntax.anchors)
95
- return ref && { ...found, ref, t: syntax.t }
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 found = await anchorOf(textDocument.uri, position.line)
100
- return found ? anchorDefinition(found.compilation, found.ref) : null
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.line)
105
- return found ? anchorHover(found.compilation, found.file, found.ref, found.t) : null
104
+ const found = await anchorOf(textDocument.uri, position)
105
+ return found ? anchorHover(found.compilation, found.ref, found.t) : null
106
106
  })
107
-
108
- connection.onCompletion(async ({ textDocument, position }) => {
109
- const found = await deckOf(textDocument.uri)
110
- return found ? complete(found.compilation, bufferLines(textDocument.uri), position) : []
111
- })
112
-
113
- connection.onRequest(SECTION_REQUEST, async ({ textDocument, position }: PositionParams): Promise<SectionAt | null> => {
114
- const found = await deckOf(textDocument.uri)
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
- const deck = file ? await decks.of(file) : undefined
125
- if (!deck) throw new Error(`${textDocument.uri} is not a source file of a Presmium deck`)
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(() => previews.close())
130
- process.once('exit', () => void previews.close())
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
  }
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }